mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 19:35:28 +00:00
perf(dm): persist decrypted DM results so reloads skip the extension
Consolidates the persisted-plaintext-decrypt-cache pattern (already used by watch history) into a shared factory, persistedPlaintextCache.js, and adds a DM cache on top of it. watchHistoryDecryptCache now delegates to the factory (same public API; tests unchanged). decryptDirectMessageEvent now also consults/writes a persisted cache keyed by the immutable DM event id (JSON of the decrypt result; flushed once per listDirectMessages batch; cleared on logout alongside the in-memory cache). DM events never change, so a reload re-decrypts only messages it has not seen and skips the (serialized, slow) nip-07 extension for the rest. Harness (dm-repro, now cold/warm): 20 DMs -> COLD nip04.decrypt 50, WARM 0 — reloads cost zero extension decrypt calls. Also excludes the actor's own key from NIP-04 remote candidates (decrypting against self is always wasted). DM unit + integration and watch-history cache tests green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
bbf8d10473
commit
8171fcb347
5 files changed
+184
-129
No files matched your search
@@ -558,6 +558,12 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
if (!normalized || seenRemotes.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
// Skip the actor's own key: a NIP-04 conversation key is derived with the
|
||||
// *other* party, so decrypting against self is always a wasted (slow,
|
||||
// serialized) extension round-trip.
|
||||
if (normalizedActor && normalized === normalizedActor) {
|
||||
return;
|
||||
}
|
||||
seenRemotes.add(normalized);
|
||||
remoteCandidates.push(normalized);
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
resolveStoragePointerValue,
|
||||
} from "../utils/storagePointer.js";
|
||||
import { RelayBatchFetcher } from "./relayBatchFetcher.js";
|
||||
import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js";
|
||||
import {
|
||||
createWatchHistoryManager,
|
||||
normalizePointerInput,
|
||||
@@ -349,6 +350,13 @@ function cloneEventForCache(event) {
|
||||
const DM_DECRYPT_CACHE_LIMIT = 256;
|
||||
const DM_EVENT_KINDS = Object.freeze([4, 1059]);
|
||||
|
||||
// Persisted (across reloads) cache of decrypted DM results keyed by the immutable
|
||||
// DM event id. DM events never change, so caching the decrypted result means a
|
||||
// reload re-decrypts only messages we haven't seen — skipping the (serialized,
|
||||
// slow) nip-07 extension for the rest. Stored as JSON of the decrypt result;
|
||||
// cleared on logout via clearDmDecryptCache().
|
||||
const dmPlaintextCache = createPersistedPlaintextCache("bitvid:dmDecrypted:v1", 1000);
|
||||
|
||||
function buildDmFilters(actorPubkey, { since, until, limit } = {}) {
|
||||
const normalizedActor = normalizeActorKey(actorPubkey);
|
||||
const filters = [];
|
||||
@@ -1140,6 +1148,7 @@ export class NostrClient {
|
||||
if (this.dmDecryptCache) {
|
||||
this.dmDecryptCache.clear();
|
||||
}
|
||||
dmPlaintextCache.clear();
|
||||
}
|
||||
|
||||
async ensureDmDecryptor() {
|
||||
@@ -1350,6 +1359,20 @@ export class NostrClient {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Persisted result from a prior session — avoids re-hitting the extension
|
||||
// to decrypt the same immutable DM on every reload.
|
||||
const persisted = dmPlaintextCache.get(eventId);
|
||||
if (persisted) {
|
||||
try {
|
||||
const parsed = JSON.parse(persisted);
|
||||
if (parsed && parsed.ok) {
|
||||
this.dmDecryptCache.set(eventId, parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch (_) {
|
||||
// fall through to a fresh decrypt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decryptDM = await this.ensureDmDecryptor();
|
||||
@@ -1368,6 +1391,11 @@ export class NostrClient {
|
||||
|
||||
if (result?.ok && eventId) {
|
||||
this.dmDecryptCache.set(eventId, result);
|
||||
try {
|
||||
dmPlaintextCache.set(eventId, JSON.stringify(result));
|
||||
} catch (_) {
|
||||
// result not serializable — skip persisting
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -1436,6 +1464,8 @@ export class NostrClient {
|
||||
settled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
sub.unsub();
|
||||
// Persist any newly-decrypted DM results once, after the batch.
|
||||
dmPlaintextCache.flush();
|
||||
messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
|
||||
resolve(messages);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Shared factory for a persisted "decrypted plaintext" cache keyed by event id.
|
||||
//
|
||||
// Several nostr surfaces (watch-history chunks, direct messages) decrypt many
|
||||
// immutable events, each of which is a serialized round-trip to the nip-07
|
||||
// extension (the private key lives in the extension and cannot be cached
|
||||
// locally). Caching the decrypted plaintext by event id means reloads decrypt
|
||||
// only events we haven't seen before, skipping the extension for the rest —
|
||||
// across sessions.
|
||||
//
|
||||
// The cached plaintext is no more sensitive than the data each surface already
|
||||
// persists (watch-history items; DM previews). Callers must clear the cache on
|
||||
// logout so a decrypted secret never outlives its session.
|
||||
//
|
||||
// @param {string} storageKey localStorage key for this cache.
|
||||
// @param {number} [maxEntries] cap on retained entries (most-recent kept).
|
||||
|
||||
export function createPersistedPlaintextCache(storageKey, maxEntries = 400) {
|
||||
/** @type {Map<string, string> | null} */
|
||||
let cache = null;
|
||||
let dirty = false;
|
||||
|
||||
function ensure() {
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
cache = new Map();
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
cache = new Map(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
cache = new Map();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function get(eventId) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
const c = ensure();
|
||||
return c.has(eventId) ? c.get(eventId) : null;
|
||||
}
|
||||
|
||||
function set(eventId, plaintext) {
|
||||
if (!eventId || typeof plaintext !== "string" || !plaintext) {
|
||||
return;
|
||||
}
|
||||
const c = ensure();
|
||||
if (c.get(eventId) === plaintext) {
|
||||
return;
|
||||
}
|
||||
c.set(eventId, plaintext);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// Persist pending writes once (call after a batch of decrypts). Bounds size.
|
||||
function flush() {
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
dirty = false;
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
let entries = Array.from(cache.entries());
|
||||
if (entries.length > maxEntries) {
|
||||
entries = entries.slice(entries.length - maxEntries);
|
||||
cache = new Map(entries);
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(entries));
|
||||
} catch (_) {
|
||||
// ignore persistence failures (quota/availability)
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cache = new Map();
|
||||
dirty = false;
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return { get, set, flush, clear };
|
||||
}
|
||||
@@ -1,95 +1,14 @@
|
||||
// Persisted cache of decrypted watch-history chunk plaintext, keyed by the
|
||||
// immutable chunk event id.
|
||||
//
|
||||
// Watch history is stored as ~150 separately encrypted chunk events. Decrypting
|
||||
// each one means a round-trip to the nip-07 extension (the private key lives in
|
||||
// the extension and cannot be cached locally), and the extension serializes
|
||||
// these — so a full re-decrypt costs many seconds on EVERY load. Chunk events
|
||||
// are addressable/replaceable: an unchanged chunk keeps its event id, while a
|
||||
// modified chunk republishes under a new id. Caching plaintext by event id means
|
||||
// reloads decrypt only the chunk(s) that actually changed (typically one),
|
||||
// skipping the extension for the rest.
|
||||
//
|
||||
// The decrypted plaintext is no more sensitive than the watch-history `items`
|
||||
// already persisted by the manager. Cleared on logout.
|
||||
// immutable chunk event id. Backed by the shared persisted-plaintext-cache
|
||||
// factory (see persistedPlaintextCache.js). An unchanged chunk keeps its event
|
||||
// id, so reloads decrypt only the chunk(s) that actually changed and skip the
|
||||
// nip-07 extension for the rest. Cleared on logout.
|
||||
|
||||
const STORAGE_KEY = "bitvid:whDecryptedChunks:v1";
|
||||
const MAX_ENTRIES = 400;
|
||||
import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js";
|
||||
|
||||
/** @type {Map<string, string> | null} */
|
||||
let cache = null;
|
||||
let dirty = false;
|
||||
const cache = createPersistedPlaintextCache("bitvid:whDecryptedChunks:v1", 400);
|
||||
|
||||
function ensure() {
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
cache = new Map();
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
cache = new Map(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
cache = new Map();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getCachedChunkPlaintext(eventId) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
const c = ensure();
|
||||
return c.has(eventId) ? c.get(eventId) : null;
|
||||
}
|
||||
|
||||
export function setCachedChunkPlaintext(eventId, plaintext) {
|
||||
if (!eventId || typeof plaintext !== "string" || !plaintext) {
|
||||
return;
|
||||
}
|
||||
const c = ensure();
|
||||
if (c.get(eventId) === plaintext) {
|
||||
return;
|
||||
}
|
||||
c.set(eventId, plaintext);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
/** Persist pending writes once (call after a batch of decrypts). Bounds size. */
|
||||
export function flushDecryptedChunkCache() {
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
dirty = false;
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
let entries = Array.from(cache.entries());
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries = entries.slice(entries.length - MAX_ENTRIES);
|
||||
cache = new Map(entries);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch (_) {
|
||||
// ignore persistence failures (quota/availability)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWatchHistoryDecryptedChunkCache() {
|
||||
cache = new Map();
|
||||
dirty = false;
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
export const getCachedChunkPlaintext = cache.get;
|
||||
export const setCachedChunkPlaintext = cache.set;
|
||||
export const flushDecryptedChunkCache = cache.flush;
|
||||
export const clearWatchHistoryDecryptedChunkCache = cache.clear;
|
||||
+41
-38
@@ -23,6 +23,9 @@ const PEER_SK = generateSecretKey();
|
||||
const PEER_PK = getPublicKey(PEER_SK);
|
||||
|
||||
let extCalls = {};
|
||||
function resetExtCalls() {
|
||||
extCalls = {};
|
||||
}
|
||||
let queue = Promise.resolve();
|
||||
function viaExtension(method, fn) {
|
||||
extCalls[method] = (extCalls[method] || 0) + 1;
|
||||
@@ -83,47 +86,47 @@ async function main() {
|
||||
};
|
||||
}, relayUrl);
|
||||
|
||||
const page = await context.newPage();
|
||||
const logs = [];
|
||||
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 300)));
|
||||
page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`));
|
||||
|
||||
await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {});
|
||||
await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {});
|
||||
|
||||
// Login via fake extension.
|
||||
try {
|
||||
await page.click('[data-testid="login-button"]', { timeout: 8000 });
|
||||
await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 });
|
||||
const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first();
|
||||
if (await extBtn.count()) await extBtn.click({ timeout: 5000 });
|
||||
else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 });
|
||||
} catch (_) {}
|
||||
await page.waitForFunction(async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, { timeout: 15000 }).catch(() => {});
|
||||
|
||||
// Probe: list DMs directly via the client.
|
||||
const result = await page.evaluate(async (userPk) => {
|
||||
const c = window.__bitvidTest__?.nostrClient;
|
||||
if (!c || typeof c.listDirectMessages !== "function") return { ok: false, reason: "no-listDirectMessages" };
|
||||
async function session(label) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {});
|
||||
await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {});
|
||||
try {
|
||||
const msgs = await c.listDirectMessages(userPk, { timeoutMs: 8000 });
|
||||
return {
|
||||
ok: true,
|
||||
count: Array.isArray(msgs) ? msgs.length : 0,
|
||||
sample: Array.isArray(msgs) ? msgs.slice(0, 3).map((m) => ({ ok: m?.ok, text: (m?.text || m?.content || "").slice(0, 40), from: (m?.pubkey || "").slice(0, 8) })) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, reason: String(e).split("\n")[0] };
|
||||
}
|
||||
}, USER_PK);
|
||||
await page.click('[data-testid="login-button"]', { timeout: 8000 });
|
||||
await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 });
|
||||
const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first();
|
||||
if (await extBtn.count()) await extBtn.click({ timeout: 5000 });
|
||||
else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 });
|
||||
} catch (_) {}
|
||||
await page.waitForFunction(async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, { timeout: 15000 }).catch(() => {});
|
||||
const result = await page.evaluate(async (userPk) => {
|
||||
const c = window.__bitvidTest__?.nostrClient;
|
||||
if (!c || typeof c.listDirectMessages !== "function") return { ok: false, reason: "no-listDirectMessages" };
|
||||
try {
|
||||
const msgs = await c.listDirectMessages(userPk, { timeoutMs: 8000 });
|
||||
return { ok: true, count: Array.isArray(msgs) ? msgs.length : 0 };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: String(e).split("\n")[0] };
|
||||
}
|
||||
}, USER_PK);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
resetExtCalls();
|
||||
const cold = await session("cold");
|
||||
const coldCalls = { ...extCalls };
|
||||
resetExtCalls();
|
||||
const warm = await session("warm");
|
||||
const warmCalls = { ...extCalls };
|
||||
|
||||
const dCold = coldCalls["nip04.decrypt"] || 0;
|
||||
const dWarm = warmCalls["nip04.decrypt"] || 0;
|
||||
console.log("\n================ DM REPRO ================");
|
||||
console.log(`listDirectMessages: ${JSON.stringify(result)}`);
|
||||
console.log(`Extension calls: ${JSON.stringify(extCalls)}`);
|
||||
const dm = logs.filter((l) => /dm|message|decrypt|permission|nip04|nip44|error|warn|fail|unavailable/i.test(l)).slice(0, 25);
|
||||
console.log(`\nConsole (DM-related):`);
|
||||
dm.forEach((l) => console.log(" " + l));
|
||||
console.log(`COLD: ${JSON.stringify(cold)} | extension calls: ${JSON.stringify(coldCalls)}`);
|
||||
console.log(`WARM: ${JSON.stringify(warm)} | extension calls: ${JSON.stringify(warmCalls)}`);
|
||||
console.log(`nip04.decrypt: cold=${dCold} warm=${dWarm} -> persist cache ${dWarm < dCold ? "WORKS ✓" : "no effect"} (saved ${dCold - dWarm} extension calls)`);
|
||||
console.log("=========================================\n");
|
||||
|
||||
await browser.close();
|
||||
|
||||
Reference in new issue
Block a user