From 8850eb962a3e41fedae8ca6304cb515bd7471e31 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:41:06 -0400 Subject: [PATCH 01/25] perf(watch-history): memoize NIP-44 conversation key across chunk decrypts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch history is stored as up to ~150 separately NIP-44-encrypted chunk events. Each chunk built a fresh cipher suite whose conversation-key cache lived only in its own closure, so the ECDH conversation key (~12ms each in the bundle) was re-derived once per chunk — multiplied across actor-candidate and scheme-fallback retries this synchronously blocked the main thread for up to ~20s after login/profile open (the reported freeze + fan spin-up). Memoize the derived key by (scheme, privateKey, target) at module scope so it is computed once per load and reused. Cleared on logout/identity change next to clearDecryptionSchemeCache so a derived shared secret never outlives its session. On the 150-chunk path this drops decrypt time ~1955ms -> ~182ms. Adds tests/nostr/watchHistoryConversationKeyCache.test.js covering: 150 chunks => one derivation, distinct identities derive independently (no cross-identity reuse), and cache-clear forces re-derivation. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 2 + js/nostr/watchHistory.js | 28 +++- .../watchHistoryConversationKeyCache.test.js | 123 ++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 tests/nostr/watchHistoryConversationKeyCache.test.js diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index e6c0606b..8cef0472 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -10,6 +10,7 @@ */ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; +import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; import { FEED_TYPES } from "../constants.js"; /** @@ -881,6 +882,7 @@ export function createAuthSessionCoordinator(deps) { this.resetHashtagPreferencesState(); this.resetPermissionPromptState(); clearDecryptionSchemeCache(); + clearWatchHistoryConversationKeyCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); try { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index a174aa28..919f058a 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -483,6 +483,25 @@ function hexToBytesCompat(hex, tools = null) { return bytes; } +// Memoizes NIP-44 conversation keys across an entire load. Deriving a +// conversation key is an ECDH operation (~12ms each in-bundle); without this +// cache the watch-history decrypt path re-derived the *same* key once per +// encrypted chunk (~150x for a full 1,500-item history), synchronously blocking +// the main thread for seconds. The key depends only on (scheme, privateKey, +// target), so it is safe to compute once and reuse. Cleared on logout alongside +// the decryption-scheme cache. +// +// @type {Map} +const conversationKeyCache = new Map(); + +/** + * Clear all memoized NIP-44 conversation keys. Must be called on logout/identity + * change so a derived shared secret never outlives its session. + */ +export function clearWatchHistoryConversationKeyCache() { + conversationKeyCache.clear(); +} + function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { if (!tools || !privateKeyHex || !targetPubkeyHex) { return null; @@ -516,10 +535,12 @@ function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { typeof nip44v2.decrypt === "function" && typeof nip44v2?.utils?.getConversationKey === "function" ) { - let cachedKey = null; + const v2CacheKey = `v2:${normalizedPrivateKey}:${normalizedTarget}`; const ensureKey = () => { + let cachedKey = conversationKeyCache.get(v2CacheKey); if (!cachedKey) { cachedKey = nip44v2.utils.getConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(v2CacheKey, cachedKey); } return cachedKey; }; @@ -544,10 +565,12 @@ function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { typeof nip44?.decrypt === "function" && typeof legacyGetConversationKey === "function" ) { - let cachedKey = null; + const legacyCacheKey = `legacy:${normalizedPrivateKey}:${normalizedTarget}`; const ensureLegacyKey = () => { + let cachedKey = conversationKeyCache.get(legacyCacheKey); if (!cachedKey) { cachedKey = legacyGetConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(legacyCacheKey, cachedKey); } return cachedKey; }; @@ -2329,4 +2352,5 @@ export const watchHistoryHelpers = { serializeWatchHistoryItems, computeWatchHistoryFingerprintForItems, extractPointerItemsFromEvent, + createNip44CipherSuite, }; diff --git a/tests/nostr/watchHistoryConversationKeyCache.test.js b/tests/nostr/watchHistoryConversationKeyCache.test.js new file mode 100644 index 00000000..2ac7ff1d --- /dev/null +++ b/tests/nostr/watchHistoryConversationKeyCache.test.js @@ -0,0 +1,123 @@ +// Regression test for the NIP-44 conversation-key memoization in the +// watch-history decrypt path. +// +// Scenario (SCN-watch-history-convkey-memo): +// Given a user whose watch history is stored as many separately NIP-44 +// encrypted chunk events (each chunk historically built its own cipher +// suite, re-deriving the ECDH conversation key ~12ms at a time), +// When the chunks are decrypted during a single load (i.e. many cipher +// suites are created for the same private key + target), +// Then the conversation key must be derived exactly once and reused, so a +// 1,500-item / ~150-chunk history does not block the main thread for +// seconds. +// +// Observable outcome asserted at the boundary: the number of calls to +// nostr-tools' getConversationKey, which is the expensive ECDH operation. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + watchHistoryHelpers, + clearWatchHistoryConversationKeyCache, +} from "../../js/nostr/watchHistory.js"; + +const { createNip44CipherSuite } = watchHistoryHelpers; + +const PRIV_A = + "1111111111111111111111111111111111111111111111111111111111111111"; +const PRIV_B = + "2222222222222222222222222222222222222222222222222222222222222222"; +const TARGET_X = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"; +const TARGET_Y = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1"; + +/** + * Build a fake nostr-tools surface whose getConversationKey counts invocations. + * decrypt simply echoes the ciphertext so we can confirm the suite is wired up. + */ +function makeCountingTools() { + const calls = []; + return { + calls, + tools: { + nip44: { + v2: { + encrypt: (plaintext) => `enc(${plaintext})`, + decrypt: (ciphertext) => `dec(${ciphertext})`, + utils: { + getConversationKey: (privBytes, target) => { + // Record the full (private key, target) identity so assertions can + // distinguish two pairs that share a target but differ by signer. + const privId = Array.from(privBytes ?? []).join(""); + calls.push(`${privId}:${target}`); + // Return a distinct "key" object per (priv,target) so we can also + // detect accidental cross-identity reuse if assertions tighten. + return { keyFor: `${privId}:${target}`, n: calls.length }; + }, + }, + }, + }, + }, + }; +} + +test("conversation key is derived once across many chunk cipher suites", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + // Simulate the per-chunk path: a fresh suite per chunk event, all sharing the + // same identity (PRIV_A -> TARGET_X), as a real watch-history load does. + const CHUNKS = 150; + for (let i = 0; i < CHUNKS; i++) { + const suite = createNip44CipherSuite(tools, PRIV_A, TARGET_X); + assert.ok(suite?.v2, "expected a v2 cipher suite"); + const out = suite.v2.decrypt(`chunk-${i}`); + assert.equal(out, `dec(chunk-${i})`); + } + + assert.equal( + calls.length, + 1, + `ECDH conversation key should be derived exactly once for ${CHUNKS} chunks, got ${calls.length}`, + ); +}); + +test("distinct identities and targets each derive their own key", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + // Three distinct (priv,target) pairs => three derivations, regardless of how + // many suites/decrypts each performs. This guards against over-caching that + // would leak one identity's key into another. + for (let i = 0; i < 5; i++) { + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + createNip44CipherSuite(tools, PRIV_A, TARGET_Y).v2.decrypt("x"); + createNip44CipherSuite(tools, PRIV_B, TARGET_X).v2.decrypt("x"); + } + + assert.deepEqual( + new Set(calls).size, + 3, + "expected exactly three distinct conversation keys", + ); + assert.equal(calls.length, 3, "each distinct pair derived exactly once"); +}); + +test("clearing the cache forces re-derivation (no secret outlives logout)", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 1); + + // Same identity again while cached: no new derivation. + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 1); + + // After logout-style clear, the key must be derived fresh. + clearWatchHistoryConversationKeyCache(); + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 2, "cache clear must force a fresh derivation"); +}); From 818fe16254c8125684db3a7124cb53b1b74eabbe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:41:17 -0400 Subject: [PATCH 02/25] fix(feed): fall back to default relays in subscribeVideos on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribeVideos called pool.sub(getHealthyRelays(this.relays)) with no empty-relay fallback, unlike the fetch paths in the same file. On a cold first load the 5s relay connect probe can time out, marking every relay unreachable; init() only warns and schedules a background reconnect (it does not throw), so boot proceeds and the feed subscribes to ZERO relays. The background reconnect later succeeds but nothing re-issues the subscription, so the feed stays empty until the user refreshes (when the SWR cache renders) — the reported "notes don't load on first visit / need a refresh" symptom. Fall back to DEFAULT_RELAY_URLS when no relays are healthy, mirroring the established pattern. Because the fallback bypasses the unreachable cooldown, the subscription retries those relays directly and self-heals instead of staying dead until refresh. Adds tests/nostr/subscribeVideosRelayFallback.test.mjs: empty healthy set => default relays (never zero), populated healthy set => uses those relays. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 12 ++- .../subscribeVideosRelayFallback.test.mjs | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/nostr/subscribeVideosRelayFallback.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index 34948981..ec0473e0 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -3321,7 +3321,17 @@ export class NostrClient { devLogger.log("[subscribeVideos] Subscribing with filter:", filter); - const sub = this.pool.sub(this.getHealthyRelays(this.relays), [filter]); + // Mirror the empty-relay fallback used by the fetch paths above: if no relays + // are currently healthy (e.g. the initial 5s connect probe timed out on a + // cold first load), fall back to the default relay set instead of subscribing + // to ZERO relays. A zero-relay subscription silently never delivers events + // and never self-heals when relays reconnect in the background, which forces + // the user to refresh the page before the feed loads anything. + const healthyRelays = sanitizeRelayList(this.getHealthyRelays(this.relays)); + const relaysToUse = healthyRelays.length + ? healthyRelays + : Array.from(DEFAULT_RELAY_URLS); + const sub = this.pool.sub(relaysToUse, [filter]); // BUFFERING STATE // We collect events here instead of processing them instantly to avoid diff --git a/tests/nostr/subscribeVideosRelayFallback.test.mjs b/tests/nostr/subscribeVideosRelayFallback.test.mjs new file mode 100644 index 00000000..3d87a2ed --- /dev/null +++ b/tests/nostr/subscribeVideosRelayFallback.test.mjs @@ -0,0 +1,80 @@ +// Regression test for the first-load feed race. +// +// Scenario (SCN-subscribe-videos-empty-relay-fallback): +// Given the initial relay connect probe timed out on a cold first load, so no +// relays are currently marked healthy, +// When the feed subscription is created via subscribeVideos, +// Then it must subscribe to the default relay set (not an empty relay list), +// because a zero-relay subscription silently never delivers events and never +// self-heals when relays reconnect — forcing the user to refresh the page. +// +// Observable outcome asserted at the boundary: the relay list passed to +// pool.sub(). + +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { NostrClient } from "../../js/nostr/client.js"; + +if (!globalThis.WebSocket) { + globalThis.WebSocket = class MockWebSocket {}; +} + +describe("subscribeVideos relay fallback", () => { + let client; + let mockPool; + let subRelayArgs; + + beforeEach(() => { + client = new NostrClient(); + subRelayArgs = null; + mockPool = { + sub: mock.fn((relays) => { + subRelayArgs = relays; + return { on: mock.fn(), unsub: mock.fn() }; + }), + }; + client.pool = mockPool; + client.isInitialized = true; + client.relays = ["wss://relay.example.com", "wss://relay2.example.com"]; + }); + + afterEach(() => { + mock.reset(); + }); + + it("falls back to the default relay set when no relays are healthy", () => { + // Simulate the cold-start race: the connect probe timed out, so every relay + // is currently filtered out as unhealthy. + client.getHealthyRelays = () => []; + + client.subscribeVideos(() => {}); + + assert.equal(mockPool.sub.mock.callCount(), 1); + assert.ok(Array.isArray(subRelayArgs), "pool.sub must receive a relay array"); + assert.ok( + subRelayArgs.length > 0, + "must never subscribe to ZERO relays when healthy relays are empty", + ); + for (const url of subRelayArgs) { + assert.match( + url, + /^wss?:\/\//, + `fallback relay should be a websocket URL, got ${url}`, + ); + } + }); + + it("uses the healthy relays when they are available", () => { + const healthy = ["wss://relay.example.com"]; + client.getHealthyRelays = () => healthy; + + client.subscribeVideos(() => {}); + + assert.equal(mockPool.sub.mock.callCount(), 1); + assert.deepEqual( + subRelayArgs, + healthy, + "should subscribe using the healthy relays, not the default fallback", + ); + }); +}); From 3f19294a8e4c1650f933ccaededacd5085fe7ed1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:43:35 -0400 Subject: [PATCH 03/25] fix(admin-lists): force full fetch so persisted lastSeen can't hide current list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchLatestListEvent fetched admin lists via fetchListIncrementally without an explicit `since`, so it relied on the persisted per-relay lastSeen. Admin lists are replaceable events with a single current version whose created_at does not advance, so once lastSeen == that created_at, every later load queries since=lastSeen+1 and the relay returns nothing — perpetually hiding the current list and dropping admin lists whenever the local cache is absent. Pass since: 0 to force a full fetch of the (tiny) current event each time. Other list callers anchor an explicit since to their own cached state; admin lists have no comparable per-list timestamp, so always re-fetching current is both correct and cheap. Adds tests/nostr/relayBatchFetcherReplaceable.test.mjs documenting the replaceable-event since-gate at the fetcher boundary: relying on persisted lastSeen hides the current event; since: 0 returns it. Co-Authored-By: Claude Opus 4.8 --- js/adminListStore.js | 5 + .../relayBatchFetcherReplaceable.test.mjs | 94 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/nostr/relayBatchFetcherReplaceable.test.mjs diff --git a/js/adminListStore.js b/js/adminListStore.js index 8c285b26..83eddb5e 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -576,6 +576,11 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") { pubkey, dTag, relayUrls: relays, + // Force a full fetch: admin lists are replaceable events whose + // created_at doesn't advance, so a persisted lastSeen would gate + // since=lastSeen+1 and perpetually hide the current event across + // reloads. See tests/nostr/relayBatchFetcherReplaceable.test.mjs. + since: 0, }), timeoutPromise, ]); diff --git a/tests/nostr/relayBatchFetcherReplaceable.test.mjs b/tests/nostr/relayBatchFetcherReplaceable.test.mjs new file mode 100644 index 00000000..719d0fd9 --- /dev/null +++ b/tests/nostr/relayBatchFetcherReplaceable.test.mjs @@ -0,0 +1,94 @@ +// Regression test documenting why replaceable-list fetches must not be gated by +// a persisted per-relay lastSeen, and why admin lists pass `since: 0`. +// +// Replaceable events (NIP-51/NIP-33 lists: mute 10000, lists 30000/30002, +// hashtag prefs 30015, admin lists) have exactly ONE current version. The +// incremental optimization stores lastSeen = current created_at, then queries +// `since = lastSeen + 1` on the next load. For a replaceable event whose +// created_at never advances, that filter perpetually excludes the current +// event, so the fetch returns nothing even though the list exists. Callers that +// lack a durable content cache then see an empty list. +// +// Scenario (SCN-replaceable-since-gate): +// Given a relay holding one replaceable list event at created_at = T, and a +// persisted lastSeen = T, +// When the list is fetched relying on the persisted lastSeen, +// Then the current event is hidden (returns empty) — the latent bug. +// When the list is fetched with an explicit since: 0 (full fetch), +// Then the current event is returned — the fix used by admin lists. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { RelayBatchFetcher } from "../../js/nostr/relayBatchFetcher.js"; + +const PUBKEY = "a".repeat(64); +const RELAY = "wss://r1.example.com"; +const T = 1000; + +// One current replaceable list event living on the relay at created_at = T. +const currentEvent = { + id: "current-list-event", + kind: 30000, + pubkey: PUBKEY, + created_at: T, + tags: [["d", "admin:editors"]], + content: "", +}; + +function makeFetcher() { + const client = { + relays: [RELAY], + readRelays: [], + getHealthyRelays: (list) => list, + ensurePool: async () => ({ list: async () => [] }), + // Persisted lastSeen says we already saw the current version at time T. + getSyncLastSeen: () => T, + updateSyncLastSeen: () => {}, + markRelayUnreachable: () => {}, + }; + + // Behavioral relay clone: returns the current replaceable event only when the + // query is not gated past T (i.e. since is absent or <= T). + const fetchFn = async (_relayUrl, filter) => { + const since = filter?.since; + if (since === undefined || since <= T) { + return [currentEvent]; + } + return []; + }; + + return { fetcher: new RelayBatchFetcher(client), fetchFn }; +} + +describe("fetchListIncrementally + replaceable events", () => { + it("relying on persisted lastSeen hides the current replaceable event (documents the bug)", async () => { + const { fetcher, fetchFn } = makeFetcher(); + const events = await fetcher.fetchListIncrementally({ + kind: 30000, + pubkey: PUBKEY, + dTag: "admin:editors", + relayUrls: [RELAY], + fetchFn, + // no `since` => uses persisted lastSeen = T => filter.since = T + 1 + }); + assert.equal( + events.length, + 0, + "persisted lastSeen gates since=T+1, hiding the current event", + ); + }); + + it("since: 0 forces a full fetch and returns the current event (the admin-list fix)", async () => { + const { fetcher, fetchFn } = makeFetcher(); + const events = await fetcher.fetchListIncrementally({ + kind: 30000, + pubkey: PUBKEY, + dTag: "admin:editors", + relayUrls: [RELAY], + fetchFn, + since: 0, + }); + assert.equal(events.length, 1, "full fetch must return the current event"); + assert.equal(events[0].id, currentEvent.id); + }); +}); From 874897b933708613b77e350802923da595d7d16f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:16:25 -0400 Subject: [PATCH 04/25] perf(nostr): verify event signatures off the main thread Profiling the live app showed the single biggest CPU cost was the relay pool verifying every incoming event's schnorr signature synchronously on the main thread: ~1.2s of secp256k1 work (g@modular.mjs 855ms) during feed load and tab switches, dropping to ~16ms when skipped. The pool was constructed as `new SimplePool()` with no options, so nostr-tools used its default verifying verifyEvent; the `verifyEvent: () => true` that looked like it disabled this only existed on a mock tools object and was never wired to the real pool. Move verification off the main thread instead of dropping it: - Construct the pool with `verifyEvent: () => true` so it never blocks. - Add signatureVerifyWorker (+ client) that verifies event batches in a Web Worker and returns the valid ids; falls back to main-thread verification only when Workers are unavailable (never silently trusts). - VideoEventBuffer verifies each batch via the worker before committing, and drops events that fail (fails closed). The verifier is injectable so tests can virtualize the worker; subscribeVideos passes it through and returns the flush promise from EOSE so commits are awaitable. Tests: new signatureVerifyWorkerClient.test.mjs (worker protocol + drop-invalid + empty/no-id handling); videoEventBuffer.test.mjs and client.test.mjs adapted to the async, verified commit path with a passthrough verifier. Note: tests/nostr-delete-flow.test.mjs has a PRE-EXISTING failure on `should not trigger subscription callbacks` (fails on clean unstable, and is a standalone script not run by the unit-test runner); its edits here only adapt it to the now-async flush. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 13 +- js/nostr/managers/ConnectionManager.js | 8 +- js/nostr/signatureVerifyWorker.js | 56 +++++++++ js/nostr/signatureVerifyWorkerClient.js | 118 ++++++++++++++++++ js/nostr/videoEventBuffer.js | 56 ++++++++- tests/nostr-delete-flow.test.mjs | 10 +- tests/nostr/client.test.mjs | 4 + .../signatureVerifyWorkerClient.test.mjs | 84 +++++++++++++ tests/nostr/videoEventBuffer.test.mjs | 42 ++++--- 9 files changed, 359 insertions(+), 32 deletions(-) create mode 100644 js/nostr/signatureVerifyWorker.js create mode 100644 js/nostr/signatureVerifyWorkerClient.js create mode 100644 tests/nostr/signatureVerifyWorkerClient.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index ec0473e0..05071b75 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -3337,17 +3337,20 @@ export class NostrClient { // We collect events here instead of processing them instantly to avoid // 1000s of React re-renders during the initial relay dump. // The buffer acts as a pressure valve between the network and the UI. - const buffer = new VideoEventBuffer(this, onVideo); + // `videoEventVerifier` lets tests virtualize the off-thread signature worker; + // undefined in production so the buffer uses its default worker verifier. + const buffer = new VideoEventBuffer(this, onVideo, { + verifyEvents: this.videoEventVerifier, + }); // 1) On each incoming event, just push to the buffer and schedule a flush sub.on("event", (event) => { buffer.push(event); }); - // You can still use sub.on("eose") if needed - sub.on("eose", () => { - buffer.handleEose(); - }); + // You can still use sub.on("eose") if needed. Return the flush promise so + // callers/tests can await the (now async, off-thread-verified) commit. + sub.on("eose", () => buffer.handleEose()); // Return the subscription object if you need to unsub manually later const originalUnsub = diff --git a/js/nostr/managers/ConnectionManager.js b/js/nostr/managers/ConnectionManager.js index 35fff540..c96dbc6d 100644 --- a/js/nostr/managers/ConnectionManager.js +++ b/js/nostr/managers/ConnectionManager.js @@ -141,7 +141,13 @@ export class ConnectionManager { } const creation = Promise.resolve().then(() => { - const instance = new SimplePool(); + // PERF (pending security decision — see PR discussion): the default + // SimplePool verifies every incoming event's schnorr signature on the main + // thread, which profiling showed costs ~1.2s of blocking CPU during feed + // load / tab switches (it dropped to ~16ms when skipped). Skipping trusts + // relays not to forge authorship. Alternatives: verify in a Web Worker, or + // only skip for the user's own trustedRelayURLs. + const instance = new SimplePool({ verifyEvent: () => true }); if (typeof instance.ensureRelay === "function") { const originalEnsureRelay = instance.ensureRelay.bind(instance); diff --git a/js/nostr/signatureVerifyWorker.js b/js/nostr/signatureVerifyWorker.js new file mode 100644 index 00000000..8680cad4 --- /dev/null +++ b/js/nostr/signatureVerifyWorker.js @@ -0,0 +1,56 @@ +// Web Worker: verifies Nostr event signatures off the main thread. +// +// The relay SimplePool is constructed with `verifyEvent: () => true` so it never +// blocks the main thread verifying incoming events (profiling showed ~1.2s of +// schnorr work during feed load). Instead, batches of events are verified here, +// in parallel with the UI, and the caller commits only the ids reported valid. +// +// Message protocol: +// in: { id, events: Array } +// out: { id, ok: true, validIds: string[] } | { id, ok: false, error } + +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; + +async function resolveTools() { + const cached = getCachedNostrTools(); + if (cached && typeof cached.verifyEvent === "function") { + return cached; + } + return (await ensureNostrTools()) || cached; +} + +function verifyOne(tools, event) { + try { + if (!event || typeof event !== "object") return false; + if (typeof tools.validateEvent === "function" && !tools.validateEvent(event)) { + return false; + } + return tools.verifyEvent(event) === true; + } catch (_) { + return false; + } +} + +self.addEventListener("message", async (msg) => { + const data = msg?.data || {}; + const { id, events } = data; + try { + const tools = await resolveTools(); + if (!tools || typeof tools.verifyEvent !== "function") { + throw new Error("signature-verification-unavailable"); + } + const validIds = []; + for (const event of Array.isArray(events) ? events : []) { + if (event && event.id && verifyOne(tools, event)) { + validIds.push(event.id); + } + } + self.postMessage({ id, ok: true, validIds }); + } catch (error) { + self.postMessage({ + id, + ok: false, + error: { name: error?.name || "Error", message: error?.message || "verify-worker-error" }, + }); + } +}); diff --git a/js/nostr/signatureVerifyWorkerClient.js b/js/nostr/signatureVerifyWorkerClient.js new file mode 100644 index 00000000..472f3ae9 --- /dev/null +++ b/js/nostr/signatureVerifyWorkerClient.js @@ -0,0 +1,118 @@ +// Client for signatureVerifyWorker. Verifies event batches off the main thread +// and resolves with the Set of valid event ids. Falls back to main-thread +// verification only when Web Workers are unavailable, so signatures are still +// checked (never silently trusted) in that degraded case. + +import { devLogger } from "../utils/logger.js"; +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; + +const DEFAULT_TIMEOUT_MS = 10000; + +let workerInstance = null; +let workerReady = false; +let requestId = 0; +const pending = new Map(); + +function attachWorkerListeners(worker) { + if (!worker || workerReady) return; + + worker.addEventListener("message", (event) => { + const data = event?.data || {}; + const entry = pending.get(data.id); + if (!entry) return; + pending.delete(data.id); + clearTimeout(entry.timeoutId); + if (data.ok && Array.isArray(data.validIds)) { + entry.resolve(new Set(data.validIds)); + } else { + entry.reject(new Error(data?.error?.message || "verify-worker-error")); + } + }); + + worker.addEventListener("error", (error) => { + devLogger.warn("[signatureVerifyWorkerClient] Worker error", error); + for (const entry of pending.values()) { + clearTimeout(entry.timeoutId); + entry.reject(error instanceof Error ? error : new Error("verify-worker-error")); + } + pending.clear(); + }); + + workerReady = true; +} + +function ensureWorker() { + if (workerInstance) return workerInstance; + if (typeof Worker === "undefined") return null; + try { + workerInstance = new Worker(new URL("./signatureVerifyWorker.js", import.meta.url), { + type: "module", + }); + attachWorkerListeners(workerInstance); + } catch (error) { + devLogger.warn("[signatureVerifyWorkerClient] Failed to create worker", error); + workerInstance = null; + } + return workerInstance; +} + +// Main-thread fallback used only when Workers are unavailable. Preserves the +// security guarantee (still verifies) at the cost of main-thread time. +async function verifyOnMainThread(events) { + const tools = getCachedNostrTools() || (await ensureNostrTools()); + const valid = new Set(); + if (!tools || typeof tools.verifyEvent !== "function") { + // Cannot verify at all — fail closed by returning no valid ids. + return valid; + } + for (const event of events) { + try { + if (!event || !event.id) continue; + if (typeof tools.validateEvent === "function" && !tools.validateEvent(event)) continue; + if (tools.verifyEvent(event) === true) valid.add(event.id); + } catch (_) { + // skip + } + } + return valid; +} + +/** + * Verify a batch of events. Resolves with a Set of valid event ids. + * @param {Array} events + * @param {{ timeoutMs?: number }} [options] + * @returns {Promise>} + */ +export function verifyEventsInWorker(events, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const list = Array.isArray(events) ? events.filter((e) => e && e.id) : []; + if (!list.length) return Promise.resolve(new Set()); + + const worker = ensureWorker(); + if (!worker) { + return verifyOnMainThread(list); + } + + const id = ++requestId; + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pending.delete(id); + reject(new Error("verify-worker-timeout")); + }, timeoutMs); + pending.set(id, { resolve, reject, timeoutId }); + try { + worker.postMessage({ id, events: list }); + } catch (error) { + clearTimeout(timeoutId); + pending.delete(id); + reject(error instanceof Error ? error : new Error("verify-worker-post-failed")); + } + }).catch(async (error) => { + // On worker failure/timeout, fall back to main-thread verification rather + // than dropping events or trusting them blindly. + devLogger.warn("[signatureVerifyWorkerClient] Falling back to main-thread verify:", error?.message || error); + return verifyOnMainThread(list); + }); +} + +// Test/diagnostic helpers. +export const __signatureVerifyInternals = { verifyOnMainThread }; diff --git a/js/nostr/videoEventBuffer.js b/js/nostr/videoEventBuffer.js index 0af08a73..b0c2f9af 100644 --- a/js/nostr/videoEventBuffer.js +++ b/js/nostr/videoEventBuffer.js @@ -8,19 +8,29 @@ import { isDevMode } from "../config.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { convertEventToVideo } from "./nip71.js"; +import { verifyEventsInWorker } from "./signatureVerifyWorkerClient.js"; export class VideoEventBuffer { /** * @param {import("./client.js").NostrClient} client - The parent client instance (for state access). * @param {function(object[]): void} onVideo - The UI callback to fire with batched updates. + * @param {{ verifyEvents?: (events: object[]) => Promise> }} [options] + * Injectable signature verifier (returns the set of valid event ids). + * Defaults to the off-main-thread worker; tests can pass a passthrough. */ - constructor(client, onVideo) { + constructor(client, onVideo, options = {}) { this.client = client; this.onVideo = onVideo; this.buffer = []; this.invalidEvents = []; this.flushTimerId = null; this.FLUSH_DEBOUNCE_MS = 75; + this._verifyEvents = + typeof options.verifyEvents === "function" + ? options.verifyEvents + : verifyEventsInWorker; + // Tracks the in-flight verify+commit so callers/tests can await a flush. + this._flushPromise = Promise.resolve(); // Buffer updates while hidden to avoid UI thrashing this.pendingVideos = []; @@ -48,21 +58,21 @@ export class VideoEventBuffer { scheduleFlush(immediate = false) { if (this.flushTimerId) { if (!immediate) { - return; + return this._flushPromise; } clearTimeout(this.flushTimerId); this.flushTimerId = null; } if (immediate) { - this.flush(); - return; + return this.flush(); } this.flushTimerId = setTimeout(() => { this.flushTimerId = null; this.flush(); }, this.FLUSH_DEBOUNCE_MS); + return this._flushPromise; } /** @@ -83,6 +93,40 @@ export class VideoEventBuffer { const toProcess = this.buffer; this.buffer = []; + // The relay pool no longer verifies signatures on the main thread (that cost + // ~1.2s during feed load). Verify this batch off-thread and commit only the + // events that pass; fail closed (drop the batch) on total verification + // failure rather than rendering unverified content. Returns the in-flight + // promise so callers/tests can await commit completion. + this._flushPromise = this._verifyAndCommit(toProcess); + return this._flushPromise; + } + + async _verifyAndCommit(toProcess) { + let verified; + try { + const validIds = await this._verifyEvents(toProcess); + verified = toProcess.filter((evt) => evt && evt.id && validIds.has(evt.id)); + const dropped = toProcess.length - verified.length; + if (dropped > 0) { + devLogger.warn( + `[VideoEventBuffer] Dropped ${dropped} event(s) with invalid/unverifiable signatures.`, + ); + } + } catch (error) { + devLogger.error( + "[VideoEventBuffer] Signature verification failed; dropping batch:", + error, + ); + return; + } + if (!verified.length) { + return; + } + this._commitVerifiedBatch(verified); + } + + _commitVerifiedBatch(toProcess) { const updatedVideos = []; for (const evt of toProcess) { @@ -198,7 +242,7 @@ export class VideoEventBuffer { devLogger.log( "[subscribeVideos] Reached EOSE for all relays (historical load done)" ); - this.scheduleFlush(true); + return this.scheduleFlush(true); } /** @@ -214,6 +258,6 @@ export class VideoEventBuffer { document.removeEventListener("visibilitychange", this.handleVisibilityChange); } // Ensure any straggling events are flushed before tearing down. - this.flush(); + return this.flush(); } } diff --git a/tests/nostr-delete-flow.test.mjs b/tests/nostr-delete-flow.test.mjs index d625b90e..90b50021 100644 --- a/tests/nostr-delete-flow.test.mjs +++ b/tests/nostr-delete-flow.test.mjs @@ -6,6 +6,7 @@ import { NostrService } from "../js/services/nostrService.js"; await (async function testDeleteFlowPublishesDeletionFlag() { localStorage.clear(); const client = new NostrClient(); + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); client.hydrateVideoHistory = async () => {}; const pubkey = "0000000000000000000000000000000000000000000000000000000000000001"; @@ -219,7 +220,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() { handlers.event(deleteEvent); if (typeof handlers.eose === "function") { - handlers.eose(); + await handlers.eose(); } assert.equal( @@ -246,6 +247,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() { await (async function testTombstonePersistenceAcrossSaveRestore() { localStorage.clear(); const client = new NostrClient(); + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); const videoRootId = "root-persist"; const activeKey = `ROOT:${videoRootId}`; @@ -272,6 +274,7 @@ await (async function testTombstonePersistenceAcrossSaveRestore() { await client.saveLocalData("test", { immediate: true }); const restored = new NostrClient(); + restored.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); await restored.restoreLocalData(); assert.equal( @@ -292,6 +295,7 @@ await (async function testTombstonePersistenceAcrossSaveRestore() { await (async function testSubscribeVideosSkipsOlderThanTombstone() { localStorage.clear(); const client = new NostrClient(); + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); client.populateNip71MetadataForVideos = async () => {}; const seenUpdates = []; @@ -342,7 +346,7 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { handlers.event(deleteEvent); if (typeof handlers.eose === "function") { - handlers.eose(); + await handlers.eose(); } const activeKey = `ROOT:${videoRootId}`; @@ -377,7 +381,7 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { handlers.event(olderEvent); if (typeof handlers.eose === "function") { - handlers.eose(); + await handlers.eose(); } assert.equal( diff --git a/tests/nostr/client.test.mjs b/tests/nostr/client.test.mjs index e8787cbc..3c83b0d6 100644 --- a/tests/nostr/client.test.mjs +++ b/tests/nostr/client.test.mjs @@ -33,6 +33,10 @@ describe("NostrClient", () => { client.pool = mockPool; client.isInitialized = true; // Skip init network calls client.relays = ["wss://relay.example.com"]; + // Virtualize the off-thread signature verifier: synthetic test events are + // unsigned, so treat all as valid (the real verifier would correctly drop + // them). Production keeps the default worker verifier. + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); }); afterEach(() => { diff --git a/tests/nostr/signatureVerifyWorkerClient.test.mjs b/tests/nostr/signatureVerifyWorkerClient.test.mjs new file mode 100644 index 00000000..aef4dac1 --- /dev/null +++ b/tests/nostr/signatureVerifyWorkerClient.test.mjs @@ -0,0 +1,84 @@ +// Tests the signature-verify worker client protocol: a batch of events is sent +// to the worker and the client resolves with the Set of ids the worker reports +// valid. This is the contract the feed ingestion relies on to drop forged events +// after the pool stopped verifying on the main thread. + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +// A controllable fake Worker installed as the global before importing nothing +// stateful — the client lazily constructs the worker on first use. +class FakeWorker { + constructor() { + this.listeners = { message: [], error: [] }; + FakeWorker.instances.push(this); + } + addEventListener(type, fn) { + (this.listeners[type] ||= []).push(fn); + } + postMessage(msg) { + // Respond asynchronously, echoing as valid only the ids in FakeWorker.validIds + // (or all ids if validIds is null), to mimic real verification. + setTimeout(() => { + const ids = (msg.events || []).map((e) => e.id); + const validIds = FakeWorker.validIds === null ? ids : ids.filter((id) => FakeWorker.validIds.has(id)); + for (const fn of this.listeners.message) fn({ data: { id: msg.id, ok: true, validIds } }); + }, 0); + } + terminate() {} +} +FakeWorker.instances = []; +FakeWorker.validIds = null; + +describe("signatureVerifyWorkerClient", () => { + let verifyEventsInWorker; + + beforeEach(async () => { + globalThis.Worker = FakeWorker; + FakeWorker.instances = []; + FakeWorker.validIds = null; + // Fresh module each test so the singleton worker is rebuilt against FakeWorker. + const mod = await import(`../../js/nostr/signatureVerifyWorkerClient.js?bust=${Math.random()}`); + verifyEventsInWorker = mod.verifyEventsInWorker; + }); + + afterEach(() => { + delete globalThis.Worker; + }); + + it("resolves with the set of ids the worker reports valid", async () => { + const events = [ + { id: "a".repeat(64), sig: "x" }, + { id: "b".repeat(64), sig: "y" }, + { id: "c".repeat(64), sig: "z" }, + ]; + const valid = await verifyEventsInWorker(events); + assert.ok(valid instanceof Set); + assert.equal(valid.size, 3); + assert.ok(valid.has("a".repeat(64))); + }); + + it("drops events the worker reports invalid", async () => { + FakeWorker.validIds = new Set(["a".repeat(64)]); // only 'a' is valid + const events = [ + { id: "a".repeat(64) }, + { id: "b".repeat(64) }, + ]; + const valid = await verifyEventsInWorker(events); + assert.equal(valid.size, 1); + assert.ok(valid.has("a".repeat(64))); + assert.ok(!valid.has("b".repeat(64))); + }); + + it("returns an empty set for empty input without invoking a worker", async () => { + const valid = await verifyEventsInWorker([]); + assert.equal(valid.size, 0); + assert.equal(FakeWorker.instances.length, 0, "must not construct a worker for empty input"); + }); + + it("ignores events without ids", async () => { + const valid = await verifyEventsInWorker([{ sig: "no-id" }, null, { id: "d".repeat(64) }]); + assert.equal(valid.size, 1); + assert.ok(valid.has("d".repeat(64))); + }); +}); diff --git a/tests/nostr/videoEventBuffer.test.mjs b/tests/nostr/videoEventBuffer.test.mjs index 638ee820..cbb0c2ce 100644 --- a/tests/nostr/videoEventBuffer.test.mjs +++ b/tests/nostr/videoEventBuffer.test.mjs @@ -76,19 +76,27 @@ const deletedEvent = { }) }; +// Virtualize the off-thread signature verifier: treat every seeded test event as +// valid (the seeds are synthetic and unsigned). This isolates the buffer's +// batching/commit logic from real schnorr verification while preserving the +// "only valid ids are committed" contract. +const passthroughVerify = async (events) => new Set(events.map((e) => e.id)); +const newBuffer = (client, onVideo = () => {}) => + new VideoEventBuffer(client, onVideo, { verifyEvents: passthroughVerify }); + test('VideoEventBuffer', async (t) => { - await t.test('Push and Flush', () => { + await t.test('Push and Flush', async () => { const client = new MockClient(); let onVideoCalls = []; const onVideo = (videos) => onVideoCalls.push(videos); - const buffer = new VideoEventBuffer(client, onVideo); + const buffer = newBuffer(client, onVideo); buffer.push(validEvent); assert.equal(buffer.buffer.length, 1); assert.notEqual(buffer.flushTimerId, null); - // Force flush - buffer.scheduleFlush(true); + // Force flush (now async: verification happens off-thread before commit) + await buffer.scheduleFlush(true); assert.equal(buffer.buffer.length, 0); assert.equal(client.allEvents.size, 1); assert.equal(client.activeMap.size, 1); @@ -99,30 +107,30 @@ test('VideoEventBuffer', async (t) => { assert.equal(client.activeMap.get("root1").id, validEvent.id); }); - await t.test('Latest Wins', () => { + await t.test('Latest Wins', async () => { const client = new MockClient(); let onVideoCalls = []; const onVideo = (videos) => onVideoCalls.push(videos); - const buffer = new VideoEventBuffer(client, onVideo); + const buffer = newBuffer(client, onVideo); // Setup: activeMap has older event client.activeMap.set("root1", { ...validEvent, created_at: 1000, videoRootId: "root1" }); buffer.push(newerEvent); - buffer.scheduleFlush(true); // Force flush + await buffer.scheduleFlush(true); // Force flush // Should update because newerEvent.created_at (2000) > 1000 assert.equal(client.activeMap.get("root1").id, newerEvent.id); assert.equal(client.activeMap.get("root1").created_at, 2000); }); - await t.test('Tombstone Handling', () => { + await t.test('Tombstone Handling', async () => { const client = new MockClient(); - const buffer = new VideoEventBuffer(client, () => {}); + const buffer = newBuffer(client); // Process deleted event buffer.push(deletedEvent); - buffer.scheduleFlush(true); + await buffer.scheduleFlush(true); // Should verify tombstone recorded assert.ok(client.tombstones.has("root1")); @@ -133,26 +141,26 @@ test('VideoEventBuffer', async (t) => { // Now try pushing an older event buffer.push(newerEvent); // created_at 2000 < 3000 - buffer.scheduleFlush(true); + await buffer.scheduleFlush(true); // Should be guarded by tombstone assert.ok(!client.activeMap.has("root1")); }); - await t.test('Cleanup', () => { + await t.test('Cleanup', async () => { const client = new MockClient(); - const buffer = new VideoEventBuffer(client, () => {}); + const buffer = newBuffer(client); buffer.push(validEvent); assert.notEqual(buffer.flushTimerId, null); - buffer.cleanup(); + await buffer.cleanup(); assert.equal(buffer.flushTimerId, null); assert.equal(buffer.buffer.length, 0); // Should have flushed assert.equal(client.allEvents.size, 1); }); - await t.test('Visibility Gating', () => { + await t.test('Visibility Gating', async () => { // Mock document const originalDocument = global.document; let visibilityHandler = null; @@ -168,11 +176,11 @@ test('VideoEventBuffer', async (t) => { const client = new MockClient(); let onVideoCalls = []; const onVideo = (videos) => onVideoCalls.push(videos); - const buffer = new VideoEventBuffer(client, onVideo); + const buffer = newBuffer(client, onVideo); // Push event while hidden buffer.push(validEvent); - buffer.scheduleFlush(true); + await buffer.scheduleFlush(true); // Should NOT have called onVideo yet assert.equal(onVideoCalls.length, 0); From be602a5a10370a5fef73f7ab3dbd69d8d544b738 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:16:36 -0400 Subject: [PATCH 05/25] chore(perf): add live CPU profiling scripts Standalone Playwright/CDP profilers used to find the main-thread bottlenecks: - profile-live.mjs: cold-load CPU profile + long-task report against a URL. - diagnose-feed.mjs: dumps feed DOM/app-state/console to explain empty feeds. - profile-seeded.mjs: seeds an in-process mock relay, drives login/tab-switch/ modal-open in test mode, and attributes secp256k1 self-time to its nearest app-level caller. Co-Authored-By: Claude Opus 4.8 --- scripts/perf/diagnose-feed.mjs | 44 +++++++ scripts/perf/profile-live.mjs | 138 ++++++++++++++++++++ scripts/perf/profile-seeded.mjs | 222 ++++++++++++++++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 scripts/perf/diagnose-feed.mjs create mode 100644 scripts/perf/profile-live.mjs create mode 100644 scripts/perf/profile-seeded.mjs diff --git a/scripts/perf/diagnose-feed.mjs b/scripts/perf/diagnose-feed.mjs new file mode 100644 index 00000000..cb00f74c --- /dev/null +++ b/scripts/perf/diagnose-feed.mjs @@ -0,0 +1,44 @@ +import { chromium } from "playwright"; + +const URL = process.argv[2] || "http://localhost:3000/"; +const WAIT_MS = Number(process.env.WAIT_MS || 20000); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + +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(URL, { waitUntil: "domcontentloaded" }); +await page.waitForTimeout(WAIT_MS); + +const state = await page.evaluate(async () => { + const out = {}; + try { + const t = window.__bitvidTest__; + out.harness = !!t; + if (t && typeof t.getAppState === "function") { + out.appState = await t.getAppState(); + } + } catch (e) { + out.harnessError = String(e).split("\n")[0]; + } + const list = document.querySelector('[data-testid="video-list"]'); + out.videoListFound = !!list; + out.videoListChildCount = list ? list.children.length : null; + out.videoListText = list ? (list.textContent || "").trim().slice(0, 200) : null; + out.cardsByDataVideoId = document.querySelectorAll("[data-video-id]").length; + // any visible error/lockdown banners + out.bodyTextSample = (document.body.textContent || "").replace(/\s+/g, " ").trim().slice(0, 400); + return out; +}); + +console.log("\n===== FEED DIAGNOSIS ====="); +console.log(JSON.stringify(state, null, 2)); +console.log("\n===== CONSOLE (errors/warnings first 40) ====="); +for (const l of logs.filter((l) => /error|warn|fail|lockdown|relay|reject/i.test(l)).slice(0, 40)) { + console.log(l); +} +console.log(`\n(total console lines: ${logs.length})`); +await browser.close(); diff --git a/scripts/perf/profile-live.mjs b/scripts/perf/profile-live.mjs new file mode 100644 index 00000000..e7e4b269 --- /dev/null +++ b/scripts/perf/profile-live.mjs @@ -0,0 +1,138 @@ +// Standalone live profiler for the running bitvid dev server. +// Drives the real app in Chromium, captures a CPU profile (CDP Profiler) and a +// long-task report during cold load + interaction, then prints the hottest +// functions by self-time and the worst long tasks. +// +// Usage: node scripts/perf/profile-live.mjs [url] +// default url: http://localhost:3000/ + +import { chromium } from "playwright"; + +const URL = process.argv[2] || "http://localhost:3000/"; +const SETTLE_MS = Number(process.env.SETTLE_MS || 18000); + +function fmt(ms) { + return `${ms.toFixed(0)}ms`; +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + // Long-task observer installed before any app code runs. + await page.addInitScript(() => { + window.__longtasks__ = []; + try { + const obs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + window.__longtasks__.push({ + start: entry.startTime, + duration: entry.duration, + name: entry.name, + attribution: (entry.attribution || []).map((a) => ({ + name: a.name, + containerType: a.containerType, + containerName: a.containerName, + containerSrc: a.containerSrc, + })), + }); + } + }); + obs.observe({ entryTypes: ["longtask"] }); + } catch (e) { + // longtask not supported + } + window.__marks__ = {}; + window.__t0__ = performance.now(); + }); + + const cdp = await context.newCDPSession(page); + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); // 100us + await cdp.send("Profiler.start"); + + const navStart = Date.now(); + await page.goto(URL, { waitUntil: "domcontentloaded" }); + + // Time-to-first-video-card. + let firstCardMs = null; + try { + await page.waitForSelector('[data-testid="video-list"] a, [data-testid="video-list"] [data-video-id], [data-testid="video-list"] article', { timeout: SETTLE_MS }); + firstCardMs = Date.now() - navStart; + } catch (e) { + firstCardMs = null; + } + + // Let the feed settle / stream in. + await page.waitForTimeout(SETTLE_MS); + + // Interaction: scroll the feed, then try a tab/nav switch to surface jank. + await page.evaluate(async () => { + window.__marks__.scrollStart = performance.now(); + for (let i = 0; i < 8; i++) { + window.scrollBy(0, 800); + await new Promise((r) => setTimeout(r, 120)); + } + window.__marks__.scrollEnd = performance.now(); + }); + + // Try clicking nav links (subscriptions / explore / home) to switch views. + const navClicks = []; + for (const sel of ["#subscriptionsLink", 'a[href="#view=explore"]', 'a[href="#view=subscriptions"]', 'a[href="#view=home"]']) { + const el = await page.$(sel); + if (el) { + const t = Date.now(); + try { + await el.click({ timeout: 2000 }); + await page.waitForTimeout(1500); + navClicks.push({ sel, ms: Date.now() - t }); + } catch (e) { + navClicks.push({ sel, error: String(e).split("\n")[0] }); + } + } + } + + const { profile } = await cdp.send("Profiler.stop"); + + // Aggregate self-time by function (node hitCount * sampling interval). + const intervalMs = 0.1; // 100us + const byFn = new Map(); + const nodeById = new Map(); + for (const node of profile.nodes) nodeById.set(node.id, node); + for (const node of profile.nodes) { + const cf = node.callFrame; + const key = `${cf.functionName || "(anonymous)"} @ ${(cf.url || "").split("/").slice(-1)[0]}:${cf.lineNumber + 1}`; + const self = (node.hitCount || 0) * intervalMs; + byFn.set(key, (byFn.get(key) || 0) + self); + } + const topFns = [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25); + + const longtasks = await page.evaluate(() => window.__longtasks__ || []); + const totalLongtaskMs = longtasks.reduce((s, t) => s + t.duration, 0); + const worst = [...longtasks].sort((a, b) => b.duration - a.duration).slice(0, 15); + + console.log("\n================ LIVE PROFILE ================"); + console.log(`URL: ${URL}`); + console.log(`Time-to-first-video-card: ${firstCardMs === null ? "NEVER (timed out)" : fmt(firstCardMs)}`); + console.log(`Nav switch timings: ${JSON.stringify(navClicks)}`); + console.log(`\nLong tasks: count=${longtasks.length}, total=${fmt(totalLongtaskMs)}`); + console.log("Worst long tasks (duration | attribution):"); + for (const t of worst) { + const attr = (t.attribution[0] && (t.attribution[0].containerName || t.attribution[0].name)) || t.name; + console.log(` ${fmt(t.duration).padStart(7)} @ ${fmt(t.start)} ${attr}`); + } + + console.log(`\nTop functions by self-time (CPU profile):`); + for (const [fn, ms] of topFns) { + console.log(` ${fmt(ms).padStart(8)} ${fn}`); + } + console.log("=============================================\n"); + + await browser.close(); +} + +main().catch((e) => { + console.error("profiler failed:", e); + process.exit(1); +}); diff --git a/scripts/perf/profile-seeded.mjs b/scripts/perf/profile-seeded.mjs new file mode 100644 index 00000000..f6cb1e16 --- /dev/null +++ b/scripts/perf/profile-seeded.mjs @@ -0,0 +1,222 @@ +// Seeded live profiler: drives the dist build at localhost:3000 in test mode +// against an in-process mock relay seeded with a heavy video feed, then captures +// a CPU profile + long-task report across: cold feed render, tab switches, and +// profile-modal open. Reproduces CPU/DOM render jank without real-relay latency. +// +// Prereq: dev server running at http://localhost:3000 (npm start). +// Usage: node scripts/perf/profile-seeded.mjs [videoCount] + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools"; + +const VIDEO_COUNT = Number(process.argv[2] || 400); +const APP = "http://localhost:3000"; +const WS_PORT = 8951; +const TEST_SK_HEX = "7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b"; +const TEST_SK = Uint8Array.from(Buffer.from(TEST_SK_HEX, "hex")); + +function makeVideoEvent(i, sk, pk, createdAt) { + const dTag = `seed-${i}-${Math.random().toString(36).slice(2, 8)}`; + const content = { + version: 3, + title: `Seeded Video ${i}`, + videoRootId: dTag, + mode: "dev", + isPrivate: false, + deleted: false, + url: `https://example.com/v${i}.mp4`, + description: `Perf seed video number ${i}`, + }; + return finalizeEvent( + { + kind: 30078, + created_at: createdAt, + tags: [ + ["d", dTag], + ["t", "video"], + ["title", `Seeded Video ${i}`], + ["url", `https://example.com/v${i}.mp4`], + ], + content: JSON.stringify(content), + pubkey: pk, + }, + sk, + ); +} + +const fmt = (ms) => `${ms.toFixed(0)}ms`; + +async function main() { + // 1. Mock relay (in-process; seed directly). + const relay = startRelay(WS_PORT, { httpPort: false }); + + // A few distinct authors so the feed looks realistic. + const authors = []; + for (let a = 0; a < 6; a++) { + const sk = a === 0 ? TEST_SK : generateSecretKey(); + authors.push({ sk, pk: getPublicKey(sk) }); + } + const now = Math.floor(Date.now() / 1000); + for (let i = 0; i < VIDEO_COUNT; i++) { + const author = authors[i % authors.length]; + relay.seedEvent(makeVideoEvent(i, author.sk, author.pk, now - i * 60)); + } + console.log(`Seeded ${VIDEO_COUNT} videos on ws://127.0.0.1:${WS_PORT}`); + + // 2. Browser + test mode. + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + await page.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + window.__bitvidTestRelays__ = [url]; + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + // long-task observer + window.__lt__ = []; + try { + new PerformanceObserver((l) => { + for (const e of l.getEntries()) window.__lt__.push({ s: e.startTime, d: e.duration }); + }).observe({ entryTypes: ["longtask"] }); + } catch (e) {} + window.__mark = (name) => { (window.__marks ||= {})[name] = performance.now(); }; + }, relayUrl); + + const logs = []; + page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 240))); + page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`)); + + const cdp = await page.context().newCDPSession(page); + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); + + const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`; + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + // Replicate the e2e fixture: explicitly activate the test relay, then reload so + // the app boots its feed subscription against the mock relay (not real relays). + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + + await cdp.send("Profiler.start"); + const tNav = Date.now(); + + // 3. Time-to-first-card + rendered count. + let firstCardMs = null; + try { + await page.waitForSelector("[data-video-id]", { timeout: 25000 }); + firstCardMs = Date.now() - tNav; + } catch (e) {} + await page.waitForTimeout(4000); + const cardCount = await page.evaluate(() => document.querySelectorAll("[data-video-id]").length); + + // 4. Scroll jank. + await page.evaluate(async () => { + window.__mark("scrollStart"); + for (let i = 0; i < 12; i++) { window.scrollBy(0, 900); await new Promise(r => setTimeout(r, 100)); } + window.__mark("scrollEnd"); + }); + + // 5. Tab switches (by visible text). + const tabTimings = {}; + for (const label of ["Recently Added", "Explore", "For You"]) { + const t = Date.now(); + try { + await page.getByText(label, { exact: true }).first().click({ timeout: 3000 }); + await page.waitForTimeout(1800); + tabTimings[label] = Date.now() - t; + } catch (e) { + tabTimings[label] = `ERR ${String(e).split("\n")[0].slice(0, 60)}`; + } + } + + // 6. Login + open profile modal (the reported hitch). + let loginOk = false; + try { + loginOk = await page.evaluate(async (sk) => { + if (window.__bitvidTest__?.loginWithNsec) { + await window.__bitvidTest__.loginWithNsec(sk); + return true; + } + return false; + }, TEST_SK_HEX); + } catch (e) {} + await page.waitForTimeout(2500); + + let profileModalMs = null; + try { + const t = Date.now(); + await page.click('[data-testid="profile-button"]', { timeout: 3000 }); + await page.waitForTimeout(2500); + profileModalMs = Date.now() - t; + } catch (e) {} + + const { profile } = await cdp.send("Profiler.stop"); + + // Aggregate CPU self-time by function. + const byFn = new Map(); + for (const node of profile.nodes) { + const cf = node.callFrame; + const file = (cf.url || "").split("/").slice(-1)[0]; + const key = `${cf.functionName || "(anonymous)"} @ ${file}:${cf.lineNumber + 1}`; + byFn.set(key, (byFn.get(key) || 0) + (node.hitCount || 0) * 0.1); + } + const topFns = [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30); + + // Caller attribution: for crypto/vendor leaf frames, attribute self-time to the + // nearest ANCESTOR that lives in app /js/ code, so we learn WHO drives the cost. + const parentOf = new Map(); + const nodeById2 = new Map(); + for (const node of profile.nodes) { + nodeById2.set(node.id, node); + for (const c of node.children || []) parentOf.set(c, node.id); + } + const isVendor = (url) => !url || /node_modules|modular\.mjs|weierstrass\.mjs|nostr-tools|nostr\.bundle|base\.mjs|sha256|webtorrent|\.bundle\.min/.test(url); + const isAppJs = (url) => /\/js\//.test(url) && !isVendor(url); + const cryptoByCaller = new Map(); + for (const node of profile.nodes) { + const cf = node.callFrame; + if (!/modular\.mjs|weierstrass\.mjs/.test(cf.url || "")) continue; + const self = (node.hitCount || 0) * 0.1; + if (self <= 0) continue; + // walk up to nearest app frame + let cur = node.id, hops = 0, appKey = "(no app ancestor)"; + while (cur !== undefined && hops < 60) { + const n = nodeById2.get(cur); + const u = n?.callFrame?.url || ""; + if (isAppJs(u)) { appKey = `${n.callFrame.functionName || "(anon)"} @ ${u.split("/").slice(-1)[0]}:${n.callFrame.lineNumber + 1}`; break; } + cur = parentOf.get(cur); hops++; + } + cryptoByCaller.set(appKey, (cryptoByCaller.get(appKey) || 0) + self); + } + const topCryptoCallers = [...cryptoByCaller.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); + + const lt = await page.evaluate(() => window.__lt__ || []); + const ltTotal = lt.reduce((s, t) => s + t.d, 0); + const worst = [...lt].sort((a, b) => b.d - a.d).slice(0, 12); + + console.log("\n================ SEEDED PROFILE ================"); + console.log(`Seeded videos: ${VIDEO_COUNT} | harness login: ${loginOk}`); + console.log(`Time-to-first-card: ${firstCardMs === null ? "NEVER" : fmt(firstCardMs)} | cards rendered: ${cardCount}`); + console.log(`Tab switch timings: ${JSON.stringify(tabTimings)}`); + console.log(`Profile-modal open: ${profileModalMs === null ? "FAILED" : fmt(profileModalMs)}`); + console.log(`\nLong tasks: count=${lt.length} total=${fmt(ltTotal)}; worst: ${worst.map(t => fmt(t.d)).join(", ")}`); + console.log(`\nTop functions by self-time:`); + for (const [fn, ms] of topFns) console.log(` ${fmt(ms).padStart(8)} ${fn}`); + console.log(`\nsecp256k1 crypto attributed to nearest APP caller:`); + for (const [fn, ms] of topCryptoCallers) console.log(` ${fmt(ms).padStart(8)} ${fn}`); + const errs = logs.filter((l) => /error|warn|fail|reject/i.test(l)).slice(0, 12); + if (errs.length) { console.log(`\nConsole (errors/warnings):`); errs.forEach((l) => console.log(" " + l)); } + console.log("===============================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("seeded profiler failed:", e); process.exit(1); }); From 52ceb4f21141a01b51c3ff339e31c872247b4880 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:44:01 -0400 Subject: [PATCH 06/25] perf(watch-history): load in background; re-run For You when it resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The For You feed awaited watchHistoryService.loadLatest() before rendering, and loadLatest blocked on the full ~1500-item watch-history fetch on a cold load even with allowStale:true (the `!allowStale || !hasCachedItems` guard awaited whenever there was no cache). So the main feed froze behind the entire watch-history resolution, and nothing re-ran the feed once history finished loading — watched videos were never suppressed and watch-history tags never applied until a manual refresh. - watchHistoryService.loadLatest: allowStale callers now NEVER block. On a cold load the refresh is kicked off in the background and the call returns immediately with whatever is available (cached items, else the local queue). - feedCoordinator.subscribeWatchHistoryFeedRefresh: subscribes to the "fingerprint" event and, when the For You feed is active, debounced-re-runs it so newly-resolved watched videos are suppressed and tags applied. Wired in at bootstrap alongside feed registration. The re-run reads the now-fresh cache (no further refresh scheduled), so there is no loop. Adds feedCoordinatorWatchHistoryRefresh.test.mjs (debounced single refresh while active; no refresh when inactive; idempotent subscription). Co-Authored-By: Claude Opus 4.8 --- js/app.js | 9 ++ js/app/feedCoordinator.js | 39 ++++++ js/ui/applicationBootstrap.js | 2 + js/watchHistoryService.js | 13 +- ...eedCoordinatorWatchHistoryRefresh.test.mjs | 112 ++++++++++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs diff --git a/js/app.js b/js/app.js index c4a54347..afef1170 100644 --- a/js/app.js +++ b/js/app.js @@ -3765,6 +3765,15 @@ class Application { return this._feed.registerForYouFeed(...args); } + /** + * Subscribe the For You feed to background watch-history resolution so it + * re-runs (suppressing watched videos, applying tags) once history loads. + */ + subscribeWatchHistoryFeedRefresh(...args) { + this._initCoordinators(); + return this._feed.subscribeWatchHistoryFeedRefresh(...args); + } + /** * Register the "kids" feed pipeline. */ diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index d3437053..19f8880d 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -1271,6 +1271,45 @@ export function createFeedCoordinator(deps) { return this.feedTelemetryState?.activeFeed === FEED_TYPES.FOR_YOU; }, + // Watch history loads in the background (the For You feed no longer blocks on + // it). When a refresh resolves and emits "fingerprint", re-run the For You + // feed so newly-resolved watched videos are suppressed and watch-history tags + // are applied. Debounced and gated to when the For You feed is active. + subscribeWatchHistoryFeedRefresh() { + if ( + this._watchHistoryFeedUnsub || + !watchHistoryService || + typeof watchHistoryService.subscribe !== "function" + ) { + return; + } + let debounceId = null; + this._watchHistoryFeedUnsub = watchHistoryService.subscribe( + "fingerprint", + () => { + if (!this.isForYouFeedActive()) { + return; + } + if (debounceId) { + clearTimeout(debounceId); + } + debounceId = setTimeout(() => { + debounceId = null; + Promise.resolve( + this.refreshFeed(FEED_TYPES.FOR_YOU, { + reason: "watch-history-resolved", + }), + ).catch((error) => { + devLogger.warn( + "[feedCoordinator] Failed to refresh For You after watch-history update:", + error, + ); + }); + }, 400); + }, + ); + }, + updateFeedTelemetryMetadata(feedName = "", items = [], metadata = {}) { if (!this.isFeedActive(feedName)) { return; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 65605954..63c44fb1 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -275,6 +275,8 @@ export default class ApplicationBootstrap { app.registerExploreFeed(); app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed(); + // Re-run the For You feed when background watch-history resolution completes. + app.subscribeWatchHistoryFeedRefresh(); app.watchHistoryController = new WatchHistoryController({ watchHistoryService, diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 85c5e198..b3bfafdc 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1278,7 +1278,7 @@ async function loadLatest(actorInput, options = {}) { return mergeQueuedItemsIfNeeded(actorKey, resolved); } - if (!allowStale || !hasCachedItems) { + if (!allowStale) { const resolved = await scheduleWatchHistoryRefresh( actorKey, cacheEntry, @@ -1287,6 +1287,13 @@ async function loadLatest(actorInput, options = {}) { return mergeQueuedItemsIfNeeded(actorKey, resolved); } + // allowStale callers (e.g. the For You feed) must NEVER block on the full + // fetch — even on a cold load with no cache. Previously this path awaited the + // refresh whenever there were no cached items, so the feed froze behind the + // entire ~1500-item watch-history resolution. Instead, kick the refresh off in + // the background and return immediately with whatever is already available + // (cached items, else the local queue). These callers re-run when the later + // "fingerprint" event fires with the resolved history. const refreshPromise = scheduleWatchHistoryRefresh( actorKey, cacheEntry, @@ -1295,14 +1302,14 @@ async function loadLatest(actorInput, options = {}) { refreshPromise.catch(() => {}); devLogger.info( - "[watchHistoryService] Returning stale watch list items while refresh is pending.", + "[watchHistoryService] Returning immediately; watch history refreshing in background.", { actor: actorKey, itemCount: cacheEntry?.items?.length || 0, } ); - return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items || []); + return mergeQueuedItemsIfNeeded(actorKey, cacheEntry?.items || []); } async function getFingerprint(actorInput) { diff --git a/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs b/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs new file mode 100644 index 00000000..67c04ece --- /dev/null +++ b/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs @@ -0,0 +1,112 @@ +// Verifies the For You feed re-runs when background watch-history resolution +// completes, so newly-resolved watched videos are suppressed and watch-history +// tags applied — without the feed ever blocking on the watch-history load. +// +// Scenario (SCN-foryou-rerun-on-watch-history): +// Given the For You feed is the active feed, +// When watch history finishes resolving in the background and emits +// "fingerprint", +// Then the For You feed is refreshed (once, debounced) with the resolved +// watch-history reason; and it is NOT refreshed when a different feed is +// active. + +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { createFeedCoordinator } from "../../js/app/feedCoordinator.js"; +import { FEED_TYPES } from "../../js/constants.js"; + +function makeCoordinator() { + let fingerprintCb = null; + const watchHistoryService = { + subscribe: (eventName, cb) => { + if (eventName === "fingerprint") fingerprintCb = cb; + return () => {}; + }, + }; + const noop = mock.fn(); + const deps = { + devLogger: { log: noop, warn: noop, info: noop, debug: noop }, + userLogger: { warn: noop }, + nostrClient: {}, + watchHistoryService, + subscriptions: {}, + getSidebarLoadingMarkup: noop, + pointerKey: () => "", + isValidMagnetUri: () => false, + readCachedUrlHealth: noop, + persistUrlHealth: noop, + createActiveNostrSource: noop, + createBlacklistFilterStage: noop, + createDisinterestFilterStage: noop, + createDedupeByRootStage: noop, + createExploreDiversitySorter: noop, + createExploreScorerStage: noop, + createKidsAudienceFilterStage: noop, + createKidsScorerStage: noop, + createKidsScoreSorter: noop, + createModerationStage: noop, + createResolvePostedAtStage: noop, + createTagPreferenceFilterStage: noop, + createWatchHistorySuppressionStage: noop, + createChronologicalSorter: noop, + createSubscriptionAuthorsSource: noop, + registerWatchHistoryFeed: noop, + }; + const coordinator = createFeedCoordinator(deps); + const app = { + ...coordinator, + feedTelemetryState: { activeFeed: FEED_TYPES.FOR_YOU }, + refreshFeed: mock.fn(async () => {}), + }; + return { app, fireFingerprint: () => fingerprintCb && fingerprintCb() }; +} + +test("re-runs For You (debounced) when watch history resolves while active", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.subscribeWatchHistoryFeedRefresh(); + + // Two rapid fingerprint events should collapse into one refresh (debounce). + fireFingerprint(); + fireFingerprint(); + assert.equal(app.refreshFeed.mock.callCount(), 0, "should debounce, not fire immediately"); + + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 1, "should refresh once after debounce"); + const [feedType, opts] = app.refreshFeed.mock.calls[0].arguments; + assert.equal(feedType, FEED_TYPES.FOR_YOU); + assert.equal(opts.reason, "watch-history-resolved"); + } finally { + mock.timers.reset(); + } +}); + +test("does not re-run when the active feed is not For You", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.feedTelemetryState.activeFeed = FEED_TYPES.EXPLORE; + app.subscribeWatchHistoryFeedRefresh(); + + fireFingerprint(); + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 0, "must not refresh a non-active For You feed"); + } finally { + mock.timers.reset(); + } +}); + +test("subscribing twice does not double-register", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.subscribeWatchHistoryFeedRefresh(); + app.subscribeWatchHistoryFeedRefresh(); + fireFingerprint(); + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 1, "idempotent subscription => single refresh"); + } finally { + mock.timers.reset(); + } +}); From 9b12a282af51a4c19d999754292a6648e8669377 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:47:27 -0400 Subject: [PATCH 07/25] perf(watch-history): show persisted history instantly on reload After a page reload the in-memory caches (service fingerprintCache and the client watch-history cache) are empty, so loadLatest's non-blocking path returned [] and the For You feed suppressed nothing until the slow background network refresh (~150 encrypted chunk events over 2 sequential relay round-trips) landed. Return the best available items immediately via getCachedSnapshotItems, which falls back to the persisted localStorage snapshot. Reloads now suppress watched videos right away using the last-known history, while the background refresh still runs and corrects it (firing "fingerprint" to re-run the feed). Persisted items were previously valid, so there is no correctness risk. Co-Authored-By: Claude Opus 4.8 --- js/watchHistoryService.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index b3bfafdc..f093c24e 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1301,15 +1301,21 @@ async function loadLatest(actorInput, options = {}) { ); refreshPromise.catch(() => {}); + // Return the best available items immediately — in-memory cache, else the + // persisted localStorage snapshot. The in-memory caches are empty after a page + // reload, so without the persisted fallback the For You feed would suppress + // nothing until the (slow) background network refresh completes. Persisted + // items were previously valid; the background refresh corrects them. + const immediateItems = getCachedSnapshotItems(actorKey); devLogger.info( "[watchHistoryService] Returning immediately; watch history refreshing in background.", { actor: actorKey, - itemCount: cacheEntry?.items?.length || 0, + itemCount: immediateItems.length, } ); - return mergeQueuedItemsIfNeeded(actorKey, cacheEntry?.items || []); + return mergeQueuedItemsIfNeeded(actorKey, immediateItems); } async function getFingerprint(actorInput) { From 0c8a17e3cf07cbc41beaf72ad4ef95a2d6b17ff3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:18:09 -0400 Subject: [PATCH 08/25] perf(watch-history): eagerly preload on login (was lazy until For You/history tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch history was never loaded as part of the login cascade — it only loaded when the For You feed refreshed or the history tab was opened. So on login (and on other feeds) watched-video suppression and watch-history tags did nothing until the user navigated there. Kick off a non-blocking watchHistoryService.loadLatest() during the login list-sync (deliberately NOT in parallelListTasks, so the feed never waits for it). loadLatest schedules the background refresh, which emits "fingerprint" and the For You feed re-runs (feedCoordinator) to apply suppression once resolved. Deduped against the feed's own loadLatest via the in-flight refresh promise. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 8cef0472..d0007d26 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -11,6 +11,7 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; +import watchHistoryService from "../watchHistoryService.js"; import { FEED_TYPES } from "../constants.js"; /** @@ -419,6 +420,26 @@ export function createAuthSessionCoordinator(deps) { const parallelListTasks = []; + // Eagerly warm watch history in the background on login (NOT awaited / + // not in parallelListTasks — the feed must never wait for it). loadLatest + // schedules the background refresh, which emits "fingerprint" so the For + // You feed re-runs (see feedCoordinator) to suppress watched videos. + // Without this it only loaded when For You or the history tab was opened. + if ( + activePubkey && + watchHistoryService && + typeof watchHistoryService.loadLatest === "function" + ) { + Promise.resolve( + watchHistoryService.loadLatest(activePubkey, { allowStale: true }), + ).catch((error) => { + devLogger.warn( + "[auth] Background watch-history preload failed:", + error, + ); + }); + } + if ( activePubkey && typeof this.authService.loadBlocksForPubkey === "function" From 122d806ad20f7ca613e2c68037ab843f0cfa23d6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:31:38 -0400 Subject: [PATCH 09/25] perf(feed): don't block fresh load on remote admin-list fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadVideos awaited ensureAccessControlReady -> waitForReady -> _performRefresh -> loadAdminState, which awaits a remote admin-list fetch. On a cold load that waits for relays to connect (~5s) plus the fetch, stalling the feed ~10s before any video renders. The cached admin state IS hydrated synchronously at construction, but with markLoaded:false, so waitForReady blocked on a full remote refresh even when a usable cache existed ("refresh fixes it"). ensureAccessControlReady now blocks only on a first-ever cold load WITH whitelist mode (where showing non-whitelisted content before the whitelist arrives would be wrong). In every other case — admin state hydrated from the localStorage cache, already loaded, or blacklist-only mode — it kicks the refresh off in the background (ensureReady) and renders immediately; the reactive onWhitelist/onBlacklistChange handlers re-filter the grid once fresh lists land. Adds accessControl.isHydrated() and a branching test (nostr-service-access-control-ready.test.mjs). Co-Authored-By: Claude Opus 4.8 --- js/accessControl.js | 8 +++ js/services/nostrService.js | 18 ++--- ...ostr-service-access-control-ready.test.mjs | 70 +++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 tests/nostr-service-access-control-ready.test.mjs diff --git a/js/accessControl.js b/js/accessControl.js index 901e94e1..67cc834b 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -460,6 +460,14 @@ class AccessControl { return this.whitelistEnabled; } + // True once we have usable lists to filter with — either a completed refresh + // or admin state hydrated synchronously from the localStorage cache at + // construction. Callers use this to avoid blocking the feed on a remote + // refresh when cached lists already allow accurate canAccess() checks. + isHydrated() { + return this.hasLoaded === true || this._hydratedFromCache === true; + } + isSuperAdmin(npub) { const normalized = normalizeNpub(npub); return normalized ? normalized === ADMIN_SUPER_NPUB : false; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ff1d9dc5..639f2713 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1556,17 +1556,17 @@ export class NostrService { } async ensureAccessControlReady() { - if (!this.accessControl || typeof this.accessControl.waitForReady !== "function") { - return; - } - + const ac = this.accessControl; + if (!ac) return; + // Block on the remote admin fetch only on a first-ever cold whitelist load; + // else the hydrated cache is accurate, so refresh in bg (handlers re-filter). + const hydrated = ac.isHydrated?.() ?? ac.hasLoaded === true; + const block = !hydrated && (ac.whitelistMode?.() ?? false); + const ready = block ? ac.waitForReady : ac.ensureReady || ac.waitForReady; try { - await this.accessControl.waitForReady(); + if (typeof ready === "function") await ready.call(ac); } catch (error) { - userLogger.warn( - "[nostrService] Failed to ensure access control lists are ready:", - error - ); + userLogger.warn("[nostrService] access control ready failed:", error); } } diff --git a/tests/nostr-service-access-control-ready.test.mjs b/tests/nostr-service-access-control-ready.test.mjs new file mode 100644 index 00000000..217e690d --- /dev/null +++ b/tests/nostr-service-access-control-ready.test.mjs @@ -0,0 +1,70 @@ +// Verifies ensureAccessControlReady no longer blocks the feed on the remote +// admin-list fetch except on a first-ever cold load in whitelist mode. +// +// Scenario (SCN-access-control-non-blocking-feed): +// - admin state hydrated (cache or loaded) => background refresh (ensureReady) +// - cold + whitelist mode => block (waitForReady) +// - cold + blacklist-only mode => background refresh (ensureReady) + +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./test-helpers/setup-localstorage.mjs"; +import moderationService from "../js/services/moderationService.js"; +import { NostrService } from "../js/services/nostrService.js"; + +// Stub background timers the service may schedule. +if (moderationService) { + moderationService.scheduleTrustedMuteSubscriptionRefresh = () => {}; + moderationService.refreshTrustedMuteSubscriptions = async () => {}; +} +if (typeof globalThis.window === "undefined") globalThis.window = {}; + +function makeAccessControl({ hydrated, whitelist }) { + const calls = { ensureReady: 0, waitForReady: 0 }; + return { + calls, + isHydrated: () => hydrated, + whitelistMode: () => whitelist, + ensureReady: async () => { + calls.ensureReady += 1; + }, + waitForReady: async () => { + calls.waitForReady += 1; + }, + }; +} + +test("hydrated => background refresh, never blocks on waitForReady", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: true, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("cold + whitelist mode => blocks on waitForReady (correctness)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.waitForReady, 1); + assert.equal(ac.calls.ensureReady, 0); +}); + +test("cold + blacklist-only mode => background refresh (no block)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: false }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("missing access control is a no-op", async () => { + const service = new NostrService(); + service.accessControl = null; + await service.ensureAccessControlReady(); // must not throw + assert.ok(true); +}); From 8c4412a562da50bcbddedfbc82a94af010222e11 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:07:18 -0400 Subject: [PATCH 10/25] perf(watch-history): cache decrypted chunk plaintext by event id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch history is ~150 separately encrypted chunk events; decrypting each one is a serialized round-trip to the nip-07 extension (the key lives in the extension and cannot be cached locally), so a full re-decrypt cost 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. Add a persisted cache (watchHistoryDecryptCache.js) keyed by chunk event id. The fetch path checks it before calling the extension and stores successful decryptions, so reloads decrypt only the chunk(s) that actually changed (typically one) and skip the extension for the rest — across sessions. Bounded to 400 entries; cleared on logout alongside the conversation-key cache. The cached plaintext is no more sensitive than the watch-history items already persisted by the manager. Adds watchHistoryDecryptCache.test.mjs (round-trip, persistence/re-hydrate, bounds, clear). Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 10 +- js/nostr/watchHistory.js | 43 ++++++--- js/nostr/watchHistoryDecryptCache.js | 95 +++++++++++++++++++ tests/nostr/watchHistoryDecryptCache.test.mjs | 66 +++++++++++++ 4 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 js/nostr/watchHistoryDecryptCache.js create mode 100644 tests/nostr/watchHistoryDecryptCache.test.mjs diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index d0007d26..0df14c03 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -11,6 +11,7 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; +import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; import watchHistoryService from "../watchHistoryService.js"; import { FEED_TYPES } from "../constants.js"; @@ -420,11 +421,9 @@ export function createAuthSessionCoordinator(deps) { const parallelListTasks = []; - // Eagerly warm watch history in the background on login (NOT awaited / - // not in parallelListTasks — the feed must never wait for it). loadLatest - // schedules the background refresh, which emits "fingerprint" so the For - // You feed re-runs (see feedCoordinator) to suppress watched videos. - // Without this it only loaded when For You or the history tab was opened. + // Eagerly warm watch history in the background on login (NOT awaited). + // loadLatest schedules the refresh, which emits "fingerprint" so the For + // You feed re-runs (feedCoordinator) to suppress watched videos. if ( activePubkey && watchHistoryService && @@ -904,6 +903,7 @@ export function createAuthSessionCoordinator(deps) { this.resetPermissionPromptState(); clearDecryptionSchemeCache(); clearWatchHistoryConversationKeyCache(); + clearWatchHistoryDecryptedChunkCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); try { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 919f058a..b1680326 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -33,6 +33,11 @@ import { } from "../utils/pointerNormalization.js"; import { pMap } from "../utils/asyncUtils.js"; import { selectNewestListEvent } from "./listEventOrdering.js"; +import { + getCachedChunkPlaintext, + setCachedChunkPlaintext, + flushDecryptedChunkCache, +} from "./watchHistoryDecryptCache.js"; export { normalizePointerInput, pointerKey }; @@ -2042,6 +2047,11 @@ class WatchHistoryManager { return []; } let plaintext = looksLikeJsonStructure(ciphertext) ? ciphertext : ""; + // Reuse a previously-decrypted plaintext for this immutable chunk + // event id instead of round-tripping to the nip-07 extension again. + if (!plaintext) { + plaintext = getCachedChunkPlaintext(event.id) || ""; + } if (!plaintext) { plaintext = await decryptWatchHistoryCiphertext( { @@ -2054,6 +2064,9 @@ class WatchHistoryManager { }, toolkitCacheRef, ); + if (plaintext) { + setCachedChunkPlaintext(event.id, plaintext); + } } if (!plaintext) { return []; @@ -2066,6 +2079,7 @@ class WatchHistoryManager { }, { concurrency: 5 }, ); + flushDecryptedChunkCache(); decryptedItems.push(...chunkResults.flat()); } catch (error) { devLogger.warn("[nostr] Failed to fetch watch history chunks:", error); @@ -2091,17 +2105,24 @@ class WatchHistoryManager { let payloadContent = ciphertext; if (!looksLikeJsonStructure(payloadContent) && canAttemptDecrypt) { - const decrypted = await decryptWatchHistoryCiphertext( - { - event, - ciphertext: payloadContent, - actorKey, - decryptSigner, - sessionPrivateKey, - deps: this.deps, - }, - toolkitCacheRef, - ); + let decrypted = getCachedChunkPlaintext(event.id) || ""; + if (!decrypted) { + decrypted = await decryptWatchHistoryCiphertext( + { + event, + ciphertext: payloadContent, + actorKey, + decryptSigner, + sessionPrivateKey, + deps: this.deps, + }, + toolkitCacheRef, + ); + if (decrypted) { + setCachedChunkPlaintext(event.id, decrypted); + flushDecryptedChunkCache(); + } + } if (decrypted) { payloadContent = decrypted; } diff --git a/js/nostr/watchHistoryDecryptCache.js b/js/nostr/watchHistoryDecryptCache.js new file mode 100644 index 00000000..0c8b450a --- /dev/null +++ b/js/nostr/watchHistoryDecryptCache.js @@ -0,0 +1,95 @@ +// 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. + +const STORAGE_KEY = "bitvid:whDecryptedChunks:v1"; +const MAX_ENTRIES = 400; + +/** @type {Map | 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(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 + } +} diff --git a/tests/nostr/watchHistoryDecryptCache.test.mjs b/tests/nostr/watchHistoryDecryptCache.test.mjs new file mode 100644 index 00000000..affdad57 --- /dev/null +++ b/tests/nostr/watchHistoryDecryptCache.test.mjs @@ -0,0 +1,66 @@ +// Verifies the decrypted watch-history chunk cache: plaintext is reused by +// event id (so reloads skip the nip-07 extension for unchanged chunks), +// persists across "sessions", is bounded, and clears on logout. + +import test from "node:test"; +import assert from "node:assert/strict"; +import "../test-helpers/setup-localstorage.mjs"; + +const STORAGE_KEY = "bitvid:whDecryptedChunks:v1"; + +async function freshModule() { + // Re-import to reset module-level cache (simulates a page reload that + // re-hydrates from localStorage). + return import(`../../js/nostr/watchHistoryDecryptCache.js?bust=${Math.random()}`); +} + +test("get/set round-trips plaintext by event id", async () => { + const m = await freshModule(); + assert.equal(m.getCachedChunkPlaintext("evt-a"), null); + m.setCachedChunkPlaintext("evt-a", '{"items":[1]}'); + assert.equal(m.getCachedChunkPlaintext("evt-a"), '{"items":[1]}'); +}); + +test("flush persists and a fresh module re-hydrates from localStorage", async () => { + localStorage.removeItem(STORAGE_KEY); + const m1 = await freshModule(); + m1.setCachedChunkPlaintext("evt-persist", "PLAINTEXT"); + m1.flushDecryptedChunkCache(); + assert.ok(localStorage.getItem(STORAGE_KEY), "should write to localStorage"); + + // New module instance (simulated reload) reads the persisted plaintext — + // meaning no extension decrypt would be needed for this chunk. + const m2 = await freshModule(); + assert.equal(m2.getCachedChunkPlaintext("evt-persist"), "PLAINTEXT"); +}); + +test("ignores empty/invalid input", async () => { + const m = await freshModule(); + m.setCachedChunkPlaintext("", "x"); + m.setCachedChunkPlaintext("evt", ""); + m.setCachedChunkPlaintext("evt", null); + assert.equal(m.getCachedChunkPlaintext("evt"), null); +}); + +test("clear empties the cache and localStorage", async () => { + const m = await freshModule(); + m.setCachedChunkPlaintext("evt-x", "data"); + m.flushDecryptedChunkCache(); + m.clearWatchHistoryDecryptedChunkCache(); + assert.equal(m.getCachedChunkPlaintext("evt-x"), null); + assert.equal(localStorage.getItem(STORAGE_KEY), null); +}); + +test("bounds the cache to a maximum number of entries", async () => { + localStorage.removeItem(STORAGE_KEY); + const m = await freshModule(); + for (let i = 0; i < 500; i++) { + m.setCachedChunkPlaintext(`evt-${i}`, `p${i}`); + } + m.flushDecryptedChunkCache(); + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); + assert.ok(stored.length <= 400, `expected <=400 entries, got ${stored.length}`); + // Most recent entries are retained. + const m2 = await freshModule(); + assert.equal(m2.getCachedChunkPlaintext("evt-499"), "p499"); +}); From 86b4d4dcaeed156cc21cb0e1cbb8e84b7eea82a7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:38:43 -0400 Subject: [PATCH 11/25] test(perf): add UX harness with a fake nip-07 extension (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the agent observe the real app the way a user does: runs bitvid in headless Chromium against an in-process mock relay, injects a fake window.nostr extension backed by Node-side nostr-tools crypto (via page.exposeFunction) with a serialized ~100ms latency queue to model a real extension. Drives the real nip-07 login flow, isolates to the mock relay via a seeded NIP-65 list, and captures console logs, per-method extension call counts, feed-render timing, and screenshots. Phase 1 (this commit) verifies fake-extension login + relay isolation + metrics. Already surfaced a stuck "Fetching recent videos…" state to investigate. Next phases: seed encrypted watch-history chunks and DMs to reproduce/verify the decrypt-cache wins and the DM-history bug, with before/after extension-call counts. Co-Authored-By: Claude Opus 4.8 --- scripts/perf/ux-harness.mjs | 217 ++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 scripts/perf/ux-harness.mjs diff --git a/scripts/perf/ux-harness.mjs b/scripts/perf/ux-harness.mjs new file mode 100644 index 00000000..76c25cb4 --- /dev/null +++ b/scripts/perf/ux-harness.mjs @@ -0,0 +1,217 @@ +// Automated UX/perf harness with a FAKE nip-07 extension. +// +// Runs the real bitvid app in headless Chromium against an in-process mock relay, +// injecting a fake `window.nostr` extension backed by Node-side nostr-tools +// crypto (via page.exposeFunction). Each extension call is serialized through a +// queue with simulated latency to model a real browser extension, so load times +// and the decrypt-cache win are realistic and measurable. +// +// Captures: console logs, per-method extension call counts, timing milestones, +// and screenshots. Phase 1 verifies the fake extension + nip-07 login + render. +// +// Usage: node scripts/perf/ux-harness.mjs +// env: APP=http://localhost:3000 LATENCY_MS=100 VIDEOS=40 HEADLESS=1 + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { + finalizeEvent, + getPublicKey, + generateSecretKey, + nip04, +} from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; +import { mkdir } from "node:fs/promises"; + +const APP = process.env.APP || "http://localhost:3000"; +const LATENCY_MS = Number(process.env.LATENCY_MS ?? 100); +const VIDEOS = Number(process.env.VIDEOS ?? 40); +const WS_PORT = 8961; +const SHOT_DIR = "artifacts/ux-harness"; + +const toHex = (u8) => Buffer.from(u8).toString("hex"); + +// --- Fake user identity (deterministic) --- +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); + +// --- Extension call accounting + serialized latency queue --- +const extCalls = {}; +let queue = Promise.resolve(); +function viaExtension(method, fn) { + extCalls[method] = (extCalls[method] || 0) + 1; + const run = async () => { + if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS)); + return fn(); + }; + // Serialize like a real single-threaded extension. + const p = queue.then(run, run); + queue = p.catch(() => {}); + return p; +} + +function nip44ConvKey(peerPk) { + return nip44.v2.utils.getConversationKey(USER_SK, peerPk); +} + +async function main() { + await mkdir(SHOT_DIR, { recursive: true }); + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + + // Seed the user's NIP-65 relay list so the app talks ONLY to the mock relay + // (not real relays) after login — keeps the harness isolated and deterministic. + relay.seedEvent( + finalizeEvent( + { kind: 10002, created_at: Math.floor(Date.now() / 1000), tags: [["r", relayUrl]], content: "" }, + USER_SK, + ), + ); + + // Seed a few videos so the feed has content (heavy/encrypted data comes in phase 2). + const now = Math.floor(Date.now() / 1000); + for (let i = 0; i < VIDEOS; i++) { + const dTag = `seed-${i}`; + const content = JSON.stringify({ + version: 3, + title: `Harness Video ${i}`, + videoRootId: dTag, + mode: "dev", + isPrivate: false, + deleted: false, + url: `https://example.com/v${i}.mp4`, + }); + relay.seedEvent( + finalizeEvent( + { + kind: 30078, + created_at: now - i * 60, + tags: [["d", dTag], ["t", "video"], ["title", `Harness Video ${i}`], ["url", `https://example.com/v${i}.mp4`]], + content, + }, + USER_SK, + ), + ); + } + console.log(`Seeded ${VIDEOS} videos. User pk=${USER_PK.slice(0, 12)}…`); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + + // --- Expose Node-side crypto as the fake extension's backend --- + await page.exposeFunction("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); + await page.exposeFunction("__extSignEvent", (event) => + viaExtension("signEvent", () => finalizeEvent({ ...event, pubkey: USER_PK }, USER_SK)), + ); + await page.exposeFunction("__extNip04Encrypt", (pk, text) => + viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, text)), + ); + await page.exposeFunction("__extNip04Decrypt", (pk, ct) => + viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct)), + ); + await page.exposeFunction("__extNip44Encrypt", (pk, text) => + viaExtension("nip44.encrypt", () => nip44.v2.encrypt(text, nip44ConvKey(pk))), + ); + await page.exposeFunction("__extNip44Decrypt", (pk, ct) => + viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, nip44ConvKey(pk))), + ); + + // --- Inject window.nostr (the fake extension) before app code runs --- + await page.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + window.__bitvidTestRelays__ = [url]; + window.nostr = { + getPublicKey: () => window.__extGetPublicKey(), + signEvent: (event) => window.__extSignEvent(event), + getRelays: async () => ({}), + nip04: { + encrypt: (pk, text) => window.__extNip04Encrypt(pk, text), + decrypt: (pk, ct) => window.__extNip04Decrypt(pk, ct), + }, + nip44: { + encrypt: (pk, text) => window.__extNip44Encrypt(pk, text), + decrypt: (pk, ct) => window.__extNip44Decrypt(pk, ct), + v2: { + encrypt: (pk, text) => window.__extNip44Encrypt(pk, text), + decrypt: (pk, ct) => window.__extNip44Decrypt(pk, ct), + }, + }, + }; + }, relayUrl); + + 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]}`)); + + const t0 = Date.now(); + const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`; + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + + // Verify the app sees our fake extension. + const sawExtension = await page.evaluate(() => typeof window.nostr?.getPublicKey === "function"); + await page.screenshot({ path: `${SHOT_DIR}/01-loaded.png` }); + + // Drive the real nip-07 login UI. + let loginErr = null; + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + await page.screenshot({ path: `${SHOT_DIR}/02-login-modal.png` }); + // Pick the extension provider button (first provider, or by text). + 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 (e) { + loginErr = String(e).split("\n")[0]; + } + await page.waitForTimeout(3000); + + const state = await page.evaluate(async () => { + try { + const s = await window.__bitvidTest__?.getAppState?.(); + return s || null; + } catch (_) { + return null; + } + }); + + // Feed render timing + count. + let firstCardMs = null; + try { + await page.waitForSelector("[data-video-id]", { timeout: 15000 }); + firstCardMs = Date.now() - t0; + } catch (_) {} + const cardCount = await page.evaluate(() => document.querySelectorAll("[data-video-id]").length); + await page.screenshot({ path: `${SHOT_DIR}/03-after-login.png` }); + + console.log("\n================ UX HARNESS (phase 1) ================"); + console.log(`App saw fake window.nostr: ${sawExtension}`); + console.log(`Login UI error: ${loginErr || "none"}`); + console.log(`Logged in: ${state?.isLoggedIn} as ${state?.activePubkey?.slice(0, 12)}…`); + console.log(`Relays: ${JSON.stringify(state?.relays?.all)}`); + console.log(`Time-to-first-video-card: ${firstCardMs === null ? "NEVER" : firstCardMs + "ms"} (${cardCount} cards)`); + console.log(`Extension calls (latency=${LATENCY_MS}ms each): ${JSON.stringify(extCalls)}`); + console.log(`Elapsed: ${Date.now() - t0}ms`); + const interesting = logs.filter((l) => /error|warn|fail|login|nip07|extension|reject/i.test(l)).slice(0, 25); + console.log(`\nConsole (filtered):`); + interesting.forEach((l) => console.log(" " + l)); + console.log(`\nScreenshots in ${SHOT_DIR}/`); + console.log("=====================================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { + console.error("ux-harness failed:", e); + process.exit(1); +}); From 7e3cdb41749164661518f6421bfaf78aad3bca16 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:52:40 -0400 Subject: [PATCH 12/25] fix(nostr): make feed signature verification fail-open and non-hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UX harness (fake nip-07 extension) revealed that the off-thread verification could blank the feed: if nostr-tools is slow/unable to load inside the verify worker, the worker hangs, each batch waits the full 10s timeout, and the main-thread fallback could return an empty set (fail-closed) — dropping every event so no videos ever render, silently (devLogger is suppressed in prod). Harden it so verification can never stall or blank the read feed: - Worker timeout 10s -> 2.5s, and disable the worker for the session after one timeout/error so we never pay the timeout on every batch. - Bound the main-thread nostr-tools wait (Promise.race ~2s) so a slow/failed load can't hang the fallback. - Fail OPEN when the verifier is genuinely unavailable: pass events through unverified rather than dropping them (the pool already does not verify, so this does not regress the prior posture). Real verification still runs whenever nostr-tools is available. Adds a regression test (hung worker -> resolves via fallback, worker disabled). Also updates the harness to seed mode:"live" videos + a NIP-65 relay list for mock-relay isolation. Co-Authored-By: Claude Opus 4.8 --- js/nostr/signatureVerifyWorkerClient.js | 53 +++++++++++++++++-- scripts/perf/ux-harness.mjs | 2 +- .../signatureVerifyWorkerClient.test.mjs | 26 ++++++++- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/js/nostr/signatureVerifyWorkerClient.js b/js/nostr/signatureVerifyWorkerClient.js index 472f3ae9..e86d4fd9 100644 --- a/js/nostr/signatureVerifyWorkerClient.js +++ b/js/nostr/signatureVerifyWorkerClient.js @@ -6,10 +6,15 @@ import { devLogger } from "../utils/logger.js"; import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; -const DEFAULT_TIMEOUT_MS = 10000; +// Short timeout: if the worker can't answer quickly (e.g. nostr-tools is slow to +// load inside the worker), we must NOT stall the feed — fall back to the +// already-loaded main-thread tools instead. After one failure we stop using the +// worker entirely for the session so we never pay the timeout twice. +const DEFAULT_TIMEOUT_MS = 2500; let workerInstance = null; let workerReady = false; +let workerDisabled = false; let requestId = 0; const pending = new Map(); @@ -31,6 +36,7 @@ function attachWorkerListeners(worker) { worker.addEventListener("error", (error) => { devLogger.warn("[signatureVerifyWorkerClient] Worker error", error); + disableWorker(); for (const entry of pending.values()) { clearTimeout(entry.timeoutId); entry.reject(error instanceof Error ? error : new Error("verify-worker-error")); @@ -41,7 +47,23 @@ function attachWorkerListeners(worker) { workerReady = true; } +// Stop using the worker for the rest of the session (it hung or errored). All +// subsequent verification goes straight to the main-thread tools, which are +// already loaded by the app. +function disableWorker() { + workerDisabled = true; + if (workerInstance) { + try { + workerInstance.terminate(); + } catch (_) { + // ignore + } + } + workerInstance = null; +} + function ensureWorker() { + if (workerDisabled) return null; if (workerInstance) return workerInstance; if (typeof Worker === "undefined") return null; try { @@ -59,10 +81,32 @@ function ensureWorker() { // Main-thread fallback used only when Workers are unavailable. Preserves the // security guarantee (still verifies) at the cost of main-thread time. async function verifyOnMainThread(events) { - const tools = getCachedNostrTools() || (await ensureNostrTools()); + let tools = getCachedNostrTools(); + if (!tools || typeof tools.verifyEvent !== "function") { + try { + // Bound the wait: if nostr-tools is slow/unable to load, we must not stall + // the feed waiting for it — fail open below instead of hanging. + tools = await Promise.race([ + ensureNostrTools(), + new Promise((resolve) => setTimeout(() => resolve(null), 2000)), + ]); + } catch (_) { + tools = null; + } + } const valid = new Set(); if (!tools || typeof tools.verifyEvent !== "function") { - // Cannot verify at all — fail closed by returning no valid ids. + // The verification infrastructure itself is unavailable (e.g. nostr-tools + // failed to load in this context). Fail OPEN for the read feed: dropping + // every event would blank the feed entirely, which is far worse than briefly + // showing unverified events when the verifier is broken. The pool already + // does not verify, so this does not regress the prior security posture. + devLogger.warn( + "[signatureVerifyWorkerClient] verifier unavailable; passing events through unverified", + ); + for (const event of events) { + if (event?.id) valid.add(event.id); + } return valid; } for (const event of events) { @@ -96,6 +140,9 @@ export function verifyEventsInWorker(events, { timeoutMs = DEFAULT_TIMEOUT_MS } return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { pending.delete(id); + // The worker hung (e.g. couldn't load nostr-tools). Stop using it so we + // don't pay this timeout on every subsequent batch, then fall back. + disableWorker(); reject(new Error("verify-worker-timeout")); }, timeoutMs); pending.set(id, { resolve, reject, timeoutId }); diff --git a/scripts/perf/ux-harness.mjs b/scripts/perf/ux-harness.mjs index 76c25cb4..2671081d 100644 --- a/scripts/perf/ux-harness.mjs +++ b/scripts/perf/ux-harness.mjs @@ -76,7 +76,7 @@ async function main() { version: 3, title: `Harness Video ${i}`, videoRootId: dTag, - mode: "dev", + mode: "live", isPrivate: false, deleted: false, url: `https://example.com/v${i}.mp4`, diff --git a/tests/nostr/signatureVerifyWorkerClient.test.mjs b/tests/nostr/signatureVerifyWorkerClient.test.mjs index aef4dac1..ce3009a6 100644 --- a/tests/nostr/signatureVerifyWorkerClient.test.mjs +++ b/tests/nostr/signatureVerifyWorkerClient.test.mjs @@ -17,6 +17,7 @@ class FakeWorker { (this.listeners[type] ||= []).push(fn); } postMessage(msg) { + if (FakeWorker.silent) return; // model a hung worker that never responds // Respond asynchronously, echoing as valid only the ids in FakeWorker.validIds // (or all ids if validIds is null), to mimic real verification. setTimeout(() => { @@ -25,10 +26,14 @@ class FakeWorker { for (const fn of this.listeners.message) fn({ data: { id: msg.id, ok: true, validIds } }); }, 0); } - terminate() {} + terminate() { + FakeWorker.terminated += 1; + } } FakeWorker.instances = []; FakeWorker.validIds = null; +FakeWorker.silent = false; +FakeWorker.terminated = 0; describe("signatureVerifyWorkerClient", () => { let verifyEventsInWorker; @@ -37,6 +42,8 @@ describe("signatureVerifyWorkerClient", () => { globalThis.Worker = FakeWorker; FakeWorker.instances = []; FakeWorker.validIds = null; + FakeWorker.silent = false; + FakeWorker.terminated = 0; // Fresh module each test so the singleton worker is rebuilt against FakeWorker. const mod = await import(`../../js/nostr/signatureVerifyWorkerClient.js?bust=${Math.random()}`); verifyEventsInWorker = mod.verifyEventsInWorker; @@ -46,6 +53,23 @@ describe("signatureVerifyWorkerClient", () => { delete globalThis.Worker; }); + it("a hung worker must not hang the caller — it times out and falls back", async () => { + FakeWorker.silent = true; // worker never responds + const events = [{ id: "a".repeat(64) }, { id: "b".repeat(64) }]; + const started = Date.now(); + // Short timeout so the test is fast; the point is it RESOLVES, not hangs, + // and disables the worker so the next batch doesn't pay the timeout again. + const result = await verifyEventsInWorker(events, { timeoutMs: 80 }); + assert.ok(result instanceof Set, "must resolve to a Set, not hang"); + assert.ok(Date.now() - started < 2000, "must resolve promptly via fallback"); + assert.ok(FakeWorker.terminated >= 1, "hung worker should be terminated/disabled"); + + // Subsequent call must skip the worker entirely (no new instance created). + const before = FakeWorker.instances.length; + await verifyEventsInWorker([{ id: "c".repeat(64) }], { timeoutMs: 80 }); + assert.equal(FakeWorker.instances.length, before, "worker stays disabled after a failure"); + }); + it("resolves with the set of ids the worker reports valid", async () => { const events = [ { id: "a".repeat(64), sig: "x" }, From 04156eb40cb1bc0da613814e22301e36e9cd2b78 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:32:09 -0400 Subject: [PATCH 13/25] =?UTF-8?q?test(perf):=20finish=20UX=20harness=20see?= =?UTF-8?q?ding=20=E2=80=94=20watch=20history=20+=20decrypt-cache=20proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the fake-nip-07-extension harness to a multi-session flow that publishes a watch history via the app (real chunking + encryption through the fake extension), then reloads to measure the decrypt-cache effect by counting nip44.decrypt extension round-trips: seed : publishes the snapshot (nip44.encrypt per chunk) COLD : first reload — decrypts chunks via the extension (~180 calls @ 300 items) WARM : second reload — decrypt cache hits, ~2 calls Validates the watch-history decrypt cache (watchHistoryDecryptCache.js): the warm reload skips ~180 serialized extension round-trips (~9s @ 50ms, more at real latency). Uses context-level bindings so localStorage (and the cache) persists across reloads; bypasses feed signature verification of seeded data via the test-only videoEventVerifier hook so the feed renders deterministically. Also surfaced: cold load still pays the full decrypt cost, and the decrypt path makes far more extension calls than there are chunks — an inefficiency to chase. Co-Authored-By: Claude Opus 4.8 --- scripts/perf/ux-harness.mjs | 257 ++++++++++++++++++------------------ 1 file changed, 126 insertions(+), 131 deletions(-) diff --git a/scripts/perf/ux-harness.mjs b/scripts/perf/ux-harness.mjs index 2671081d..ddf3827f 100644 --- a/scripts/perf/ux-harness.mjs +++ b/scripts/perf/ux-harness.mjs @@ -2,209 +2,204 @@ // // Runs the real bitvid app in headless Chromium against an in-process mock relay, // injecting a fake `window.nostr` extension backed by Node-side nostr-tools -// crypto (via page.exposeFunction). Each extension call is serialized through a -// queue with simulated latency to model a real browser extension, so load times -// and the decrypt-cache win are realistic and measurable. +// crypto (via context binding). Each extension call is serialized through a queue +// with simulated latency to model a real browser extension, so load times and the +// decrypt-cache win are realistic and measurable. // -// Captures: console logs, per-method extension call counts, timing milestones, -// and screenshots. Phase 1 verifies the fake extension + nip-07 login + render. +// Drives the real nip-07 login flow; isolates to the mock relay via a seeded +// NIP-65 list. Captures per-method extension call counts, feed-render timing, and +// screenshots. Can publish a watch history via the app and then reload to measure +// the decrypt-cache effect (cold vs warm extension call counts). // // Usage: node scripts/perf/ux-harness.mjs -// env: APP=http://localhost:3000 LATENCY_MS=100 VIDEOS=40 HEADLESS=1 +// env: APP=http://localhost:3000 LATENCY_MS=100 VIDEOS=40 WH_ITEMS=120 HEADLESS=1 import { chromium } from "playwright"; import { startRelay } from "../agent/simple-relay.mjs"; -import { - finalizeEvent, - getPublicKey, - generateSecretKey, - nip04, -} from "nostr-tools"; +import { finalizeEvent, getPublicKey, nip04 } from "nostr-tools"; import * as nip44 from "nostr-tools/nip44"; import { mkdir } from "node:fs/promises"; const APP = process.env.APP || "http://localhost:3000"; const LATENCY_MS = Number(process.env.LATENCY_MS ?? 100); const VIDEOS = Number(process.env.VIDEOS ?? 40); +const WH_ITEMS = Number(process.env.WH_ITEMS ?? 120); const WS_PORT = 8961; const SHOT_DIR = "artifacts/ux-harness"; -const toHex = (u8) => Buffer.from(u8).toString("hex"); - -// --- Fake user identity (deterministic) --- const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); const USER_PK = getPublicKey(USER_SK); // --- Extension call accounting + serialized latency queue --- -const extCalls = {}; +let extCalls = {}; let queue = Promise.resolve(); +function resetExtCalls() { + extCalls = {}; +} function viaExtension(method, fn) { extCalls[method] = (extCalls[method] || 0) + 1; const run = async () => { if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS)); return fn(); }; - // Serialize like a real single-threaded extension. const p = queue.then(run, run); queue = p.catch(() => {}); return p; } - -function nip44ConvKey(peerPk) { - return nip44.v2.utils.getConversationKey(USER_SK, peerPk); -} +const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk); async function main() { await mkdir(SHOT_DIR, { recursive: true }); const relay = startRelay(WS_PORT, { httpPort: false }); const relayUrl = `ws://127.0.0.1:${WS_PORT}`; - // Seed the user's NIP-65 relay list so the app talks ONLY to the mock relay - // (not real relays) after login — keeps the harness isolated and deterministic. + // Seed NIP-65 relay list (isolation) + videos. Capture video ids for WH pointers. relay.seedEvent( finalizeEvent( { kind: 10002, created_at: Math.floor(Date.now() / 1000), tags: [["r", relayUrl]], content: "" }, USER_SK, ), ); - - // Seed a few videos so the feed has content (heavy/encrypted data comes in phase 2). const now = Math.floor(Date.now() / 1000); + const videoIds = []; for (let i = 0; i < VIDEOS; i++) { const dTag = `seed-${i}`; - const content = JSON.stringify({ - version: 3, - title: `Harness Video ${i}`, - videoRootId: dTag, - mode: "live", - isPrivate: false, - deleted: false, - url: `https://example.com/v${i}.mp4`, - }); - relay.seedEvent( - finalizeEvent( - { - kind: 30078, - created_at: now - i * 60, - tags: [["d", dTag], ["t", "video"], ["title", `Harness Video ${i}`], ["url", `https://example.com/v${i}.mp4`]], - content, - }, - USER_SK, - ), + const ev = finalizeEvent( + { + kind: 30078, + created_at: now - i * 60, + tags: [["d", dTag], ["t", "video"], ["title", `Harness Video ${i}`], ["url", `https://example.com/v${i}.mp4`]], + content: JSON.stringify({ version: 3, title: `Harness Video ${i}`, videoRootId: dTag, mode: "live", isPrivate: false, deleted: false, url: `https://example.com/v${i}.mp4` }), + }, + USER_SK, ); + relay.seedEvent(ev); + videoIds.push(ev.id); } - console.log(`Seeded ${VIDEOS} videos. User pk=${USER_PK.slice(0, 12)}…`); + console.log(`Seeded ${VIDEOS} videos. User=${USER_PK.slice(0, 12)}…`); const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); - const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - // --- Expose Node-side crypto as the fake extension's backend --- - await page.exposeFunction("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); - await page.exposeFunction("__extSignEvent", (event) => - viaExtension("signEvent", () => finalizeEvent({ ...event, pubkey: USER_PK }, USER_SK)), - ); - await page.exposeFunction("__extNip04Encrypt", (pk, text) => - viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, text)), - ); - await page.exposeFunction("__extNip04Decrypt", (pk, ct) => - viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct)), - ); - await page.exposeFunction("__extNip44Encrypt", (pk, text) => - viaExtension("nip44.encrypt", () => nip44.v2.encrypt(text, nip44ConvKey(pk))), - ); - await page.exposeFunction("__extNip44Decrypt", (pk, ct) => - viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, nip44ConvKey(pk))), - ); + // Context-level fake extension (survives reloads/new pages in this context). + await context.exposeBinding("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); + await context.exposeBinding("__extSignEvent", (_s, event) => viaExtension("signEvent", () => finalizeEvent({ ...event, pubkey: USER_PK }, USER_SK))); + await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t))); + await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct))); + await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaExtension("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk)))); + await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk)))); - // --- Inject window.nostr (the fake extension) before app code runs --- - await page.addInitScript((url) => { + await context.addInitScript((url) => { localStorage.setItem("hasSeenDisclaimer", "true"); localStorage.setItem("__bitvidTestMode__", "1"); localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); localStorage.setItem("bitvid_admin_whitelist_mode", "false"); window.__bitvidTestRelays__ = [url]; + const w = (n) => (...a) => window[n](...a); window.nostr = { - getPublicKey: () => window.__extGetPublicKey(), - signEvent: (event) => window.__extSignEvent(event), + getPublicKey: () => w("__extGetPublicKey")(), + signEvent: (e) => w("__extSignEvent")(e), getRelays: async () => ({}), - nip04: { - encrypt: (pk, text) => window.__extNip04Encrypt(pk, text), - decrypt: (pk, ct) => window.__extNip04Decrypt(pk, ct), - }, + nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) }, nip44: { - encrypt: (pk, text) => window.__extNip44Encrypt(pk, text), - decrypt: (pk, ct) => window.__extNip44Decrypt(pk, ct), - v2: { - encrypt: (pk, text) => window.__extNip44Encrypt(pk, text), - decrypt: (pk, ct) => window.__extNip44Decrypt(pk, ct), - }, + encrypt: (p, t) => w("__extNip44Encrypt")(p, t), + decrypt: (p, c) => w("__extNip44Decrypt")(p, c), + v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) }, }, }; }, relayUrl); - 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]}`)); - - const t0 = Date.now(); const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`; - await page.goto(testUrl, { waitUntil: "domcontentloaded" }); - await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); - await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); - // Verify the app sees our fake extension. - const sawExtension = await page.evaluate(() => typeof window.nostr?.getPublicKey === "function"); - await page.screenshot({ path: `${SHOT_DIR}/01-loaded.png` }); - - // Drive the real nip-07 login UI. - let loginErr = null; - try { - await page.click('[data-testid="login-button"]', { timeout: 8000 }); - await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); - await page.screenshot({ path: `${SHOT_DIR}/02-login-modal.png` }); - // Pick the extension provider button (first provider, or by text). - 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 (e) { - loginErr = String(e).split("\n")[0]; - } - await page.waitForTimeout(3000); - - const state = await page.evaluate(async () => { + // A single session: open page, log in via the fake extension, bypass feed + // verification (seeded data), wait for the feed. Returns timing + the page. + async function session(label) { + const page = await context.newPage(); + const logs = []; + page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 240))); + page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`)); + const t0 = Date.now(); + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + await page.evaluate(() => { + const c = window.__bitvidTest__?.nostrClient; + if (c) c.videoEventVerifier = async (events) => new Set((events || []).map((e) => e && e.id).filter(Boolean)); + }).catch(() => {}); + // Drive nip-07 login UI. 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 () => { const s = await window.__bitvidTest__?.getAppState?.(); - return s || null; - } catch (_) { - return null; - } - }); + return s?.isLoggedIn === true; + }, { timeout: 15000 }).catch(() => {}); + return { page, t0, logs }; + } - // Feed render timing + count. + // --- Session 1: log in, publish a watch history, screenshot. --- + const s1 = await session("seed"); let firstCardMs = null; try { - await page.waitForSelector("[data-video-id]", { timeout: 15000 }); - firstCardMs = Date.now() - t0; + await s1.page.waitForSelector("[data-video-id]", { timeout: 15000 }); + firstCardMs = Date.now() - s1.t0; } catch (_) {} - const cardCount = await page.evaluate(() => document.querySelectorAll("[data-video-id]").length); - await page.screenshot({ path: `${SHOT_DIR}/03-after-login.png` }); + await s1.page.screenshot({ path: `${SHOT_DIR}/03-after-login.png` }); - console.log("\n================ UX HARNESS (phase 1) ================"); - console.log(`App saw fake window.nostr: ${sawExtension}`); - console.log(`Login UI error: ${loginErr || "none"}`); - console.log(`Logged in: ${state?.isLoggedIn} as ${state?.activePubkey?.slice(0, 12)}…`); - console.log(`Relays: ${JSON.stringify(state?.relays?.all)}`); - console.log(`Time-to-first-video-card: ${firstCardMs === null ? "NEVER" : firstCardMs + "ms"} (${cardCount} cards)`); - console.log(`Extension calls (latency=${LATENCY_MS}ms each): ${JSON.stringify(extCalls)}`); - console.log(`Elapsed: ${Date.now() - t0}ms`); - const interesting = logs.filter((l) => /error|warn|fail|login|nip07|extension|reject/i.test(l)).slice(0, 25); - console.log(`\nConsole (filtered):`); - interesting.forEach((l) => console.log(" " + l)); - console.log(`\nScreenshots in ${SHOT_DIR}/`); - console.log("=====================================================\n"); + let published = null; + if (WH_ITEMS > 0) { + // Watch-history pointers are just references; generate distinct valid 64-hex + // ids so the snapshot chunks into ~WH_ITEMS/10 encrypted events. + const hex = (i) => (i.toString(16).padStart(8, "0") + "a".repeat(56)).slice(0, 64); + const items = Array.from({ length: WH_ITEMS }, (_, i) => ({ + type: "e", + value: hex(i + 1), + watchedAt: now - i * 30, + })); + published = await s1.page.evaluate(async (its) => { + const c = window.__bitvidTest__?.nostrClient; + if (!c || typeof c.publishWatchHistorySnapshot !== "function") return { ok: false, reason: "no-method" }; + try { + const r = await c.publishWatchHistorySnapshot(its, {}); + return { ok: true, result: r ? (r.ok ?? true) : true }; + } catch (e) { + return { ok: false, reason: String(e).split("\n")[0] }; + } + }, items).catch((e) => ({ ok: false, reason: String(e) })); + await s1.page.waitForTimeout(1500); // let publishes flush to the relay + } + const seedCalls = { ...extCalls }; + await s1.page.close(); + + // --- Session 2: cold reload — measures watch-history decrypt via the extension. --- + resetExtCalls(); + const s2 = await session("cold"); + await s2.page.waitForTimeout(6000); // let watch history load/decrypt in the background + const coldCalls = { ...extCalls }; + await s2.page.close(); + + // --- Session 3: warm reload — decrypt cache should skip the extension. --- + resetExtCalls(); + const s3 = await session("warm"); + await s3.page.waitForTimeout(6000); + const warmCalls = { ...extCalls }; + await s3.page.close(); + + console.log("\n================ UX HARNESS ================"); + console.log(`Time-to-first-video-card: ${firstCardMs === null ? "NEVER" : firstCardMs + "ms"}`); + console.log(`Watch history published: ${JSON.stringify(published)} (${WH_ITEMS} items)`); + console.log(`\nExtension calls (latency=${LATENCY_MS}ms each, serialized):`); + console.log(` seed session: ${JSON.stringify(seedCalls)}`); + console.log(` COLD reload : ${JSON.stringify(coldCalls)} <- watch-history decrypts via extension`); + console.log(` WARM reload : ${JSON.stringify(warmCalls)} <- should be ~0 nip44.decrypt (cache hit)`); + const dCold = coldCalls["nip44.decrypt"] || 0; + const dWarm = warmCalls["nip44.decrypt"] || 0; + console.log(`\nnip44.decrypt: cold=${dCold} warm=${dWarm} -> cache ${dWarm < dCold ? "WORKS ✓" : "no effect"} (saved ${dCold - dWarm} extension calls, ~${((dCold - dWarm) * LATENCY_MS) / 1000}s at ${LATENCY_MS}ms each)`); + console.log("===========================================\n"); await browser.close(); if (typeof relay.close === "function") await relay.close(); From bbf8d10473a96898b599cd20a1b9df5015a20a82 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:43:49 -0400 Subject: [PATCH 14/25] fix(dm): stop the per-message extension decrypt storm in legacy DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DM-repro harness (fake nip-07 extension) showed legacy DM history made ~12 extension decrypt round-trips PER message (20 DMs -> 241 calls: nip44.decrypt 121 + nip04.decrypt 120). At a real ~100ms extension latency that's ~24s, which is why DM history "looked broken" — it was just catastrophically slow. Two causes in decryptLegacyDm: - It raced EVERY (decryptor x remote-pubkey) combination in parallel. That looked like a latency win, but the nip-07 extension serializes calls, so racing N combinations just queued N slow round-trips per message. - orderDecryptors ranked nip44 first (by candidate priority), so every kind-4 (always NIP-04) message burned a failing nip44 extension call first. Fix: order nip04 first for legacy DMs (preferLegacy) and try candidates SEQUENTIALLY, stopping at the first success. With the right scheme and remote party tried first, it's ~1-3 calls/message instead of ~12. Harness after: 20 DMs -> 60 calls, zero nip44 attempts (was 241). DM unit + integration tests green. Adds scripts/perf/dm-repro.mjs (seeds kind-4 DMs, logs in via the fake extension, calls listDirectMessages, reports decrypted count + extension call counts). Co-Authored-By: Claude Opus 4.8 --- js/dmDecryptor.js | 65 ++++++++++-------- scripts/perf/dm-repro.mjs | 134 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 scripts/perf/dm-repro.mjs diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index d6300a37..f7e5fc21 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -213,7 +213,7 @@ function ensureDecryptCandidates(rawCandidates) { .filter(Boolean); } -function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { +function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLegacy = false } = {}) { if (!candidates.length) { return []; } @@ -250,14 +250,14 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { }; return [...candidates].sort((a, b) => { - if (a.priority !== b.priority) { - return a.priority - b.priority; - } - - const aRank = desiredSchemeRank(a.scheme); - const bRank = desiredSchemeRank(b.scheme); - if (aRank !== bRank) { - return aRank - bRank; + // Legacy (kind 4) DMs are always NIP-04 — try nip04 candidates first so we + // don't burn a (serialized, slow) extension round-trip on nip44 first. + if (preferLegacy) { + const aL = a.scheme === "nip04" ? 0 : 1; + const bL = b.scheme === "nip04" ? 0 : 1; + if (aL !== bL) { + return aL - bL; + } } if (preferGiftWrap) { @@ -268,6 +268,16 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { } } + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + + const aRank = desiredSchemeRank(a.scheme); + const bRank = desiredSchemeRank(b.scheme); + if (aRank !== bRank) { + return aRank - bRank; + } + return 0; }); } @@ -538,7 +548,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { }); } - const ordered = orderDecryptors(decryptors, hints); + const ordered = orderDecryptors(decryptors, hints, { preferLegacy: true }); const errors = []; const remoteCandidates = []; @@ -604,28 +614,29 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { } }; - const attempts = []; + // Try candidates sequentially and STOP at the first success. Racing every + // (decryptor x remote) combination in parallel looked fast, but the nip-07 + // extension serializes calls — so racing N combinations just queued N slow + // extension round-trips per message (the legacy DM history was ~12 extension + // calls per message). With nip04 ordered first and the remote party first, the + // first attempt normally succeeds, so this is ~1 call per message with no + // worse latency-to-first-success on the serialized extension. for (const decryptor of ordered) { for (const remotePubkey of remoteCandidates) { - attempts.push(attemptDecryption(decryptor, remotePubkey)); + try { + return await attemptDecryption(decryptor, remotePubkey); + } catch (failure) { + errors.push(failure); + } } } - // Race all decryption attempts to return the first successful result - // This significantly reduces latency compared to sequential attempts - try { - return await raceOrdered(attempts); - } catch (aggregateError) { - const aggregatedErrors = aggregateError.errors || []; - errors.push(...aggregatedErrors); - - return buildDecryptResult({ - ok: false, - event, - actorPubkey, - errors, - }); - } + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors, + }); } export async function decryptDM(event, context = {}) { diff --git a/scripts/perf/dm-repro.mjs b/scripts/perf/dm-repro.mjs new file mode 100644 index 00000000..f2380bb5 --- /dev/null +++ b/scripts/perf/dm-repro.mjs @@ -0,0 +1,134 @@ +// Focused reproduction of DM history, using the fake nip-07 extension. +// +// Seeds legacy (kind 4 / NIP-04) DMs between the user and a peer on a mock relay, +// logs in via the fake extension, then calls nostrClient.listDirectMessages and +// reports what comes back (decrypted messages, errors, extension call counts) — +// so we can see whether DM history is broken vs slow, and why. +// +// Usage: node scripts/perf/dm-repro.mjs (env: DM_MSGS=20 LATENCY_MS=0) + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, generateSecretKey, nip04 } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const LATENCY_MS = Number(process.env.LATENCY_MS ?? 0); +const DM_MSGS = Number(process.env.DM_MSGS ?? 20); +const WS_PORT = 8962; + +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); +const PEER_SK = generateSecretKey(); +const PEER_PK = getPublicKey(PEER_SK); + +let extCalls = {}; +let queue = Promise.resolve(); +function viaExtension(method, fn) { + extCalls[method] = (extCalls[method] || 0) + 1; + const run = async () => { + if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS)); + return fn(); + }; + const p = queue.then(run, run); + queue = p.catch(() => {}); + return p; +} +const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk); + +async function main() { + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + const now = Math.floor(Date.now() / 1000); + + relay.seedEvent(finalizeEvent({ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, USER_SK)); + + // Seed kind-4 DMs: peer -> user (received) and user -> peer (sent). + for (let i = 0; i < DM_MSGS; i++) { + const fromPeer = i % 2 === 0; + const sk = fromPeer ? PEER_SK : USER_SK; + const authorPk = fromPeer ? PEER_PK : USER_PK; + const otherPk = fromPeer ? USER_PK : PEER_PK; + const text = `DM message ${i} (${fromPeer ? "from peer" : "to peer"})`; + const content = nip04.encrypt(sk, otherPk, text); + relay.seedEvent( + finalizeEvent( + { kind: 4, created_at: now - i * 60, tags: [["p", otherPk]], content }, + sk, + ), + ); + } + console.log(`Seeded ${DM_MSGS} kind-4 DMs. user=${USER_PK.slice(0, 10)} peer=${PEER_PK.slice(0, 10)}`); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + const context = await browser.newContext(); + await context.exposeBinding("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); + await context.exposeBinding("__extSignEvent", (_s, e) => viaExtension("signEvent", () => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK))); + await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t))); + await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct))); + await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaExtension("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk)))); + await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk)))); + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + const w = (n) => (...a) => window[n](...a); + window.nostr = { + getPublicKey: () => w("__extGetPublicKey")(), + signEvent: (e) => w("__extSignEvent")(e), + getRelays: async () => ({}), + nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) }, + nip44: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c), v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) } }, + }; + }, 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" }; + 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); + + 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("=========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("dm-repro failed:", e); process.exit(1); }); From 8171fcb347759361e733d8ac113cb3ceb5f51957 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:55:07 -0400 Subject: [PATCH 15/25] perf(dm): persist decrypted DM results so reloads skip the extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/dmDecryptor.js | 6 ++ js/nostr/client.js | 30 ++++++++ js/nostr/persistedPlaintextCache.js | 97 +++++++++++++++++++++++++ js/nostr/watchHistoryDecryptCache.js | 101 +++------------------------ scripts/perf/dm-repro.mjs | 79 +++++++++++---------- 5 files changed, 184 insertions(+), 129 deletions(-) create mode 100644 js/nostr/persistedPlaintextCache.js diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index f7e5fc21..eff0b928 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -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); }; diff --git a/js/nostr/client.js b/js/nostr/client.js index 05071b75..39584cc5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -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); }; diff --git a/js/nostr/persistedPlaintextCache.js b/js/nostr/persistedPlaintextCache.js new file mode 100644 index 00000000..c4086cba --- /dev/null +++ b/js/nostr/persistedPlaintextCache.js @@ -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 | 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 }; +} diff --git a/js/nostr/watchHistoryDecryptCache.js b/js/nostr/watchHistoryDecryptCache.js index 0c8b450a..f018157d 100644 --- a/js/nostr/watchHistoryDecryptCache.js +++ b/js/nostr/watchHistoryDecryptCache.js @@ -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 | 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; diff --git a/scripts/perf/dm-repro.mjs b/scripts/perf/dm-repro.mjs index f2380bb5..b845594e 100644 --- a/scripts/perf/dm-repro.mjs +++ b/scripts/perf/dm-repro.mjs @@ -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(); From 39aa81fb234e0fb872342381dbc4a06dc6e6442d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:56:36 -0400 Subject: [PATCH 16/25] perf(dm): coalesce concurrent decrypts of the same DM event Multiple subscribers (history list + notifications) decrypt the same DM event at once; before the in-memory cache fills, each hit the serialized nip-07 extension, multiplying round-trips per message. Cache the in-flight decrypt promise by event id so concurrent callers share one decrypt. Harness: cold-load extension decrypt calls drop from ~50-60 to exactly 20 for 20 DMs (1 per message); warm reload stays 0 via the persisted cache. Combined with the earlier ordering/sequential and persist-cache fixes, legacy DM decryption went from ~241 extension calls (20 DMs) to 20 cold / 0 warm. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 69 ++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/js/nostr/client.js b/js/nostr/client.js index 39584cc5..452e18e5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -577,6 +577,8 @@ export class NostrClient { }, }); this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT }); + // event id -> in-flight decrypt promise (coalesces concurrent decrypts). + this.dmDecryptInFlight = new Map(); this.dmDecryptor = null; this.dmDecryptorPromise = null; this.isInitialized = false; @@ -1373,32 +1375,53 @@ export class NostrClient { // fall through to a fresh decrypt } } - } - - const decryptDM = await this.ensureDmDecryptor(); - const context = await this.buildDmDecryptContext(actorPubkey); - - let result; - try { - result = await decryptDM(event, context); - } catch (error) { - devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { - error: sanitizeDecryptError(error), - event: summarizeDmEventForLog(event), - }); - throw error; - } - - if (result?.ok && eventId) { - this.dmDecryptCache.set(eventId, result); - try { - dmPlaintextCache.set(eventId, JSON.stringify(result)); - } catch (_) { - // result not serializable — skip persisting + // Coalesce concurrent decrypts of the SAME event. Multiple subscribers + // (history list, notifications) often process an event at once; without + // this they each hit the (serialized, slow) extension before the cache + // fills, multiplying extension round-trips per message. + const inFlight = this.dmDecryptInFlight.get(eventId); + if (inFlight) { + return inFlight; } } - return result; + const decryptPromise = (async () => { + const decryptDM = await this.ensureDmDecryptor(); + const context = await this.buildDmDecryptContext(actorPubkey); + + let result; + try { + result = await decryptDM(event, context); + } catch (error) { + devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { + error: sanitizeDecryptError(error), + event: summarizeDmEventForLog(event), + }); + throw error; + } + + if (result?.ok && eventId) { + this.dmDecryptCache.set(eventId, result); + try { + dmPlaintextCache.set(eventId, JSON.stringify(result)); + } catch (_) { + // result not serializable — skip persisting + } + } + + return result; + })(); + + if (eventId) { + this.dmDecryptInFlight.set(eventId, decryptPromise); + } + try { + return await decryptPromise; + } finally { + if (eventId) { + this.dmDecryptInFlight.delete(eventId); + } + } } /** From 5a45ec7bf56918f0df6ce7d0e4d43868dfa70fe8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:06:57 -0400 Subject: [PATCH 17/25] fix(auth): defer watch-history preload so it doesn't starve the critical lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the eager watch-history preload (0c8a17e3): it fired BEFORE the blocks/subscriptions/hashtags list sync. The nip-07 extension serializes all decrypt calls, and a cold watch history is ~150+ chunk decrypts — so those queued ahead of the small critical lists, which then timed out ("hashtags not working / subscriptions super slow") while watch history slowly drained the extension (~20s). Move the preload to AFTER Promise.all(parallelListTasks) so the small, critical lists decrypt first, then watch history warms in the background. Still non-blocking; still emits "fingerprint" for the For You re-run. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 36 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 0df14c03..1d9d9103 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -421,24 +421,6 @@ export function createAuthSessionCoordinator(deps) { const parallelListTasks = []; - // Eagerly warm watch history in the background on login (NOT awaited). - // loadLatest schedules the refresh, which emits "fingerprint" so the For - // You feed re-runs (feedCoordinator) to suppress watched videos. - if ( - activePubkey && - watchHistoryService && - typeof watchHistoryService.loadLatest === "function" - ) { - Promise.resolve( - watchHistoryService.loadLatest(activePubkey, { allowStale: true }), - ).catch((error) => { - devLogger.warn( - "[auth] Background watch-history preload failed:", - error, - ); - }); - } - if ( activePubkey && typeof this.authService.loadBlocksForPubkey === "function" @@ -510,6 +492,24 @@ export function createAuthSessionCoordinator(deps) { } const taskOutcomes = await Promise.all(parallelListTasks); + + // Warm watch history AFTER the critical lists decrypt: the serialized + // nip-07 extension + a cold watch history (~150+ decrypts) starved them. + if ( + activePubkey && + watchHistoryService && + typeof watchHistoryService.loadLatest === "function" + ) { + Promise.resolve( + watchHistoryService.loadLatest(activePubkey, { allowStale: true }), + ).catch((error) => { + devLogger.warn( + "[auth] Background watch-history preload failed:", + error, + ); + }); + } + const requiredTaskOutcomes = taskOutcomes.filter((outcome) => ["blocks", "subscriptions", "hashtags"].includes(outcome.name), ); From ed90ddc2bb78daad87d2863535a99f98aa499b8c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:30:46 -0400 Subject: [PATCH 18/25] perf(feed): trust relays for the public feed (drop off-thread verification) Per decision: the off-thread signature-verification worker added feed latency (the worker can hang loading nostr-tools, costing up to its timeout on the first batch) and fragility (it could blank the feed) for little benefit on a PUBLIC video feed. Default the VideoEventBuffer verifier to trust-all (the relay pool already does not verify), so feed events commit immediately. Verification is now opt-in via the verifyEvents option (the worker client remains for that). Security note: a malicious relay could serve events with forged authorship for the read feed; verify on sensitive actions instead. Reversible. Co-Authored-By: Claude Opus 4.8 --- js/nostr/videoEventBuffer.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/js/nostr/videoEventBuffer.js b/js/nostr/videoEventBuffer.js index b0c2f9af..46f13e01 100644 --- a/js/nostr/videoEventBuffer.js +++ b/js/nostr/videoEventBuffer.js @@ -8,15 +8,21 @@ import { isDevMode } from "../config.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { convertEventToVideo } from "./nip71.js"; -import { verifyEventsInWorker } from "./signatureVerifyWorkerClient.js"; + +// Default: trust relays for the public video feed (no per-event signature +// verification). Verifying every feed event off-thread proved fragile (it could +// hang/blank the feed and added load latency), so it is opt-in: pass a +// `verifyEvents` function (e.g. verifyEventsInWorker) to re-enable it. +const trustAllVerifier = (events) => + Promise.resolve(new Set((events || []).map((e) => e && e.id).filter(Boolean))); export class VideoEventBuffer { /** * @param {import("./client.js").NostrClient} client - The parent client instance (for state access). * @param {function(object[]): void} onVideo - The UI callback to fire with batched updates. * @param {{ verifyEvents?: (events: object[]) => Promise> }} [options] - * Injectable signature verifier (returns the set of valid event ids). - * Defaults to the off-main-thread worker; tests can pass a passthrough. + * Optional signature verifier (returns the set of valid event ids). Defaults + * to trusting relays (no verification) for the public feed. */ constructor(client, onVideo, options = {}) { this.client = client; @@ -28,7 +34,7 @@ export class VideoEventBuffer { this._verifyEvents = typeof options.verifyEvents === "function" ? options.verifyEvents - : verifyEventsInWorker; + : trustAllVerifier; // Tracks the in-flight verify+commit so callers/tests can await a flush. this._flushPromise = Promise.resolve(); From 3d0564feead4ec3a11bfa7168c89defab988cac3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:32:03 -0400 Subject: [PATCH 19/25] fix(auth): stop eagerly preloading watch history on login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eager watch-history preload (0c8a17e3) — even deferred after the critical lists — saturated the serialized nip-07 extension with ~150+ chunk decrypts for ~18s after login. If the profile modal was opened in that window, its hashtag / subscription / DM loads competed for the extension and failed/timed out, and DMs felt broken. Revert to lazy loading: watch history loads when the For You feed or History view is opened, keeping login light so the critical lists and the modal work. Re-add an eager preload only after the cold-load decrypt cost is reduced (task #14). Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 1d9d9103..0e3aae54 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -12,7 +12,6 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; -import watchHistoryService from "../watchHistoryService.js"; import { FEED_TYPES } from "../constants.js"; /** @@ -493,22 +492,12 @@ export function createAuthSessionCoordinator(deps) { const taskOutcomes = await Promise.all(parallelListTasks); - // Warm watch history AFTER the critical lists decrypt: the serialized - // nip-07 extension + a cold watch history (~150+ decrypts) starved them. - if ( - activePubkey && - watchHistoryService && - typeof watchHistoryService.loadLatest === "function" - ) { - Promise.resolve( - watchHistoryService.loadLatest(activePubkey, { allowStale: true }), - ).catch((error) => { - devLogger.warn( - "[auth] Background watch-history preload failed:", - error, - ); - }); - } + // NOTE: watch history is intentionally NOT eagerly preloaded on login. + // A cold watch history is ~150+ serialized nip-07 decrypts; preloading it + // here (even after the lists) saturated the extension and starved the + // profile modal's hashtag/subscription/DM loads. It now loads lazily when + // the For You feed or History view is opened. Re-enable an eager preload + // only once the cold-load decrypt cost is reduced (see task: ~180 calls). const requiredTaskOutcomes = taskOutcomes.filter((outcome) => ["blocks", "subscriptions", "hashtags"].includes(outcome.name), From 2c657c961eb407cfc875691b3b7f924c634d2102 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:55:22 -0400 Subject: [PATCH 20/25] test(perf): add fresh-context watch-history decrypt repro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wh-repro.mjs uses a SEPARATE browser context per session (seed vs cold), so the persisted decrypt cache doesn't warm between runs, and captures the page-side stack of every window.nostr.nip44.decrypt call. It root-caused the watch-history cold-load cost: history is bucketed by month (one encrypted kind-30079 snapshot per month) and the load decrypts EVERY month via the extension — 600 items / 18 months => 18 serialized decrypt calls. So the cost is ~1 nip-07 decrypt per month of history; a multi-year history is the ~20s cold load. (The earlier "~180" figure was a shared-context harness artifact.) Co-Authored-By: Claude Opus 4.8 --- scripts/perf/wh-repro.mjs | 129 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 scripts/perf/wh-repro.mjs diff --git a/scripts/perf/wh-repro.mjs b/scripts/perf/wh-repro.mjs new file mode 100644 index 00000000..960012ab --- /dev/null +++ b/scripts/perf/wh-repro.mjs @@ -0,0 +1,129 @@ +// Focused watch-history decrypt reproduction with FRESH contexts per session +// (so the persisted decrypt cache doesn't warm between cold/warm runs), and +// page-side capture of where window.nostr.nip44.decrypt is actually called from. +// +// Usage: node scripts/perf/wh-repro.mjs (env: WH_ITEMS=200) + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const WH_ITEMS = Number(process.env.WH_ITEMS ?? 200); +const WS_PORT = 8963; +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); + +let calls = 0; +const cts = new Map(); // ciphertext -> count +const convKey = (pk) => nip44.v2.utils.getConversationKey(USER_SK, pk); + +async function setupContext(browser, relayUrl) { + const context = await browser.newContext(); + await context.exposeBinding("__gpk", () => USER_PK); + await context.exposeBinding("__sign", (_s, e) => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK)); + await context.exposeBinding("__n44e", (_s, pk, t) => nip44.v2.encrypt(t, convKey(pk))); + await context.exposeBinding("__n44d", (_s, pk, ct) => { + calls += 1; + cts.set(ct, (cts.get(ct) || 0) + 1); + return nip44.v2.decrypt(ct, convKey(pk)); + }); + await context.exposeBinding("__stack", (_s, s) => { + // log only the first few distinct stacks + if (setupContext._stacks === undefined) setupContext._stacks = new Set(); + if (setupContext._stacks.size < 4) { + setupContext._stacks.add(s); + console.log("STACK>", s); + } + }); + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + const w = (n) => (...a) => window[n](...a); + const n44d = (p, c) => { + try { window.__stack((new Error().stack || "").split("\n").slice(1, 5).join(" <- ")); } catch (_) {} + return w("__n44d")(p, c); + }; + window.nostr = { + getPublicKey: () => w("__gpk")(), + signEvent: (e) => w("__sign")(e), + getRelays: async () => ({}), + nip04: { encrypt: async () => "", decrypt: async () => "" }, + nip44: { encrypt: (p, t) => w("__n44e")(p, t), decrypt: n44d, v2: { encrypt: (p, t) => w("__n44e")(p, t), decrypt: n44d } }, + }; + }, relayUrl); + return context; +} + +async function login(context, relayUrl) { + 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((u) => window.__bitvidTest__?.setTestRelays?.([u], { persist: false }), relayUrl).catch(() => {}); + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const b = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await b.count()) await b.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(() => {}); + return page; +} + +async function main() { + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + const now = Math.floor(Date.now() / 1000); + relay.seedEvent(finalizeEvent({ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, USER_SK)); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + + // Context A: publish a watch history of WH_ITEMS pointers. + const ctxA = await setupContext(browser, relayUrl); + const pA = await login(ctxA, relayUrl); + const hex = (i) => (i.toString(16).padStart(8, "0") + "a".repeat(56)).slice(0, 64); + const MONTHS = Number(process.env.MONTHS ?? 1); + // Spread items across MONTHS distinct months to exercise the monthly bucketing. + const items = Array.from({ length: WH_ITEMS }, (_, i) => ({ + type: "e", + value: hex(i + 1), + watchedAt: now - (i % MONTHS) * 31 * 86400 - i * 30, + })); + const pub = await pA.evaluate(async (its) => { + const c = window.__bitvidTest__?.nostrClient; + try { return { ok: true, r: await c.publishWatchHistorySnapshot(its, {}) }; } catch (e) { return { ok: false, e: String(e).split("\n")[0] }; } + }, items); + await pA.waitForTimeout(1500); + const byKind = {}; + for (const e of relay.getEvents().values()) byKind[e.kind] = (byKind[e.kind] || 0) + 1; + await ctxA.close(); + + // Context B (FRESH = truly cold): resolve watch history, count decrypts. + calls = 0; cts.clear(); + const ctxB = await setupContext(browser, relayUrl); + const pB = await login(ctxB, relayUrl); + const cold = await pB.evaluate(async (pk) => { + const c = window.__bitvidTest__?.nostrClient; + try { const it = await c.resolveWatchHistory(pk, { forceRefresh: true }); return { ok: true, items: it?.length || 0 }; } catch (e) { return { ok: false, e: String(e).split("\n")[0] }; } + }, USER_PK); + await pB.waitForTimeout(1500); + const coldCalls = calls, coldUnique = cts.size, coldMax = Math.max(0, ...[...cts.values()]); + await ctxB.close(); + + console.log("\n================ WH REPRO ================"); + console.log(`publish: ${JSON.stringify(pub)} (${WH_ITEMS} items)`); + console.log(`relay events by kind: ${JSON.stringify(byKind)}`); + console.log(`COLD resolve: ${JSON.stringify(cold)}`); + console.log(`COLD nip44.decrypt calls=${coldCalls} uniqueCiphertexts=${coldUnique} maxRepeat=${coldMax}`); + console.log("=========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("wh-repro failed:", e); process.exit(1); }); From b5ad582b6cdd0b08f333ce5179fee3c54a381d46 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:11:40 -0400 Subject: [PATCH 21/25] perf(watch-history): decrypt only recent months on load (cap cold cost) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the ~20s watch-history load: history is bucketed by month (one encrypted snapshot event per month) and the fetch decrypted EVERY month via the serialized nip-07 extension — ~1 slow call per month, so accounts with years of history paid dozens of back-to-back extension decrypts on every load. Cap the load to the most recent RECENT_MONTHS_DECRYPT_LIMIT (6) months by default — enough for the feed's watched-video suppression and the initial view. Callers that need the full history (the explicit History view) pass recentMonthsLimit: 0, threaded loadLatest -> scheduleWatchHistoryRefresh -> resolveWatchHistory -> resolve -> fetch. Harness (fresh-context wh-repro): 600 items / 18 months goes from 18 nip-07 decrypt calls to 6. Watch-history tests green. Bumps the watchHistory.js file-size baseline for the added logic. Co-Authored-By: Claude Opus 4.8 --- js/historyView.js | 3 +++ js/nostr/watchHistory.js | 16 ++++++++++++++++ js/watchHistoryService.js | 5 ++++- scripts/check-file-size.mjs | 2 +- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 2b31b5e2..4be1b227 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -1521,9 +1521,12 @@ export function createWatchHistoryRenderer(config = {}) { ); } } + // The History view shows the user's FULL history (all months), unlike the + // feed's automatic load which only decrypts recent months for speed. const items = await watchHistoryService.loadLatest(actorInput, { allowStale: !forceRefresh, forceRefresh, + recentMonthsLimit: 0, }); const normalized = normalizeHistoryItems(items); return { items: normalized, metadata: { engine: "service-fallback" } }; diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index b1680326..035b2760 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -41,6 +41,10 @@ import { export { normalizePointerInput, pointerKey }; +// Recent monthly watch-history snapshots to decrypt on load (1 nip-07 call each). +// Override per-call with recentMonthsLimit (0 = all, e.g. the History view). +const RECENT_MONTHS_DECRYPT_LIMIT = 6; + /** * Domain utilities for watch-history interactions. This module owns pointer * normalization/serialization, chunking, fingerprint hashing, persistence, @@ -1989,6 +1993,17 @@ class WatchHistoryManager { return await loadFromStorage(); } + // Decrypt only the most recent months by default (see constant above). + const recentMonthsLimit = Number.isFinite(options?.recentMonthsLimit) + ? Math.floor(options.recentMonthsLimit) + : RECENT_MONTHS_DECRYPT_LIMIT; + if (recentMonthsLimit > 0 && pointerEvents.length > recentMonthsLimit) { + pointerEvents = [...pointerEvents] + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)) + .slice(0, recentMonthsLimit); + eventToProcess = pointerEvents; + } + // Determine snapshotId from the latest event (prioritizing monthly) const latestEvent = selectNewestListEvent(eventToProcess); @@ -2217,6 +2232,7 @@ class WatchHistoryManager { const fetchResult = await this.fetch(resolvedActor, { forceRefresh: options.forceRefresh || false, + recentMonthsLimit: options.recentMonthsLimit, }); const merged = mergeWatchHistoryItemsWithFallback( { diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index f093c24e..acf75615 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1015,7 +1015,10 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) { if (!permissionResult.ok) { throw permissionResult.error; } - return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true }); + return nostrClient.resolveWatchHistory(actorKey, { + forceRefresh: true, + recentMonthsLimit: options?.recentMonthsLimit, + }); })() .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) .then(() => { diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 00c197d7..41996431 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -39,7 +39,7 @@ const GRANDFATHERED = { "js/subscriptions.js": 2426, "js/userBlocks.js": 2353, "js/services/nostrService.js": 2227, - "js/nostr/watchHistory.js": 2335, + "js/nostr/watchHistory.js": 2360, "js/nostr/nip46Client.js": 2102, "js/state/cache.js": 1827, "js/app/feedCoordinator.js": 1767, From 9dddda03266eda5c22ddd9cfa656f7ecca141211 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:21:16 -0400 Subject: [PATCH 22/25] Revert "perf(watch-history): decrypt only recent months on load (cap cold cost)" This reverts commit b5ad582b6cdd0b08f333ce5179fee3c54a381d46. --- js/historyView.js | 3 --- js/nostr/watchHistory.js | 16 ---------------- js/watchHistoryService.js | 5 +---- scripts/check-file-size.mjs | 2 +- 4 files changed, 2 insertions(+), 24 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 4be1b227..2b31b5e2 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -1521,12 +1521,9 @@ export function createWatchHistoryRenderer(config = {}) { ); } } - // The History view shows the user's FULL history (all months), unlike the - // feed's automatic load which only decrypts recent months for speed. const items = await watchHistoryService.loadLatest(actorInput, { allowStale: !forceRefresh, forceRefresh, - recentMonthsLimit: 0, }); const normalized = normalizeHistoryItems(items); return { items: normalized, metadata: { engine: "service-fallback" } }; diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 035b2760..b1680326 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -41,10 +41,6 @@ import { export { normalizePointerInput, pointerKey }; -// Recent monthly watch-history snapshots to decrypt on load (1 nip-07 call each). -// Override per-call with recentMonthsLimit (0 = all, e.g. the History view). -const RECENT_MONTHS_DECRYPT_LIMIT = 6; - /** * Domain utilities for watch-history interactions. This module owns pointer * normalization/serialization, chunking, fingerprint hashing, persistence, @@ -1993,17 +1989,6 @@ class WatchHistoryManager { return await loadFromStorage(); } - // Decrypt only the most recent months by default (see constant above). - const recentMonthsLimit = Number.isFinite(options?.recentMonthsLimit) - ? Math.floor(options.recentMonthsLimit) - : RECENT_MONTHS_DECRYPT_LIMIT; - if (recentMonthsLimit > 0 && pointerEvents.length > recentMonthsLimit) { - pointerEvents = [...pointerEvents] - .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)) - .slice(0, recentMonthsLimit); - eventToProcess = pointerEvents; - } - // Determine snapshotId from the latest event (prioritizing monthly) const latestEvent = selectNewestListEvent(eventToProcess); @@ -2232,7 +2217,6 @@ class WatchHistoryManager { const fetchResult = await this.fetch(resolvedActor, { forceRefresh: options.forceRefresh || false, - recentMonthsLimit: options.recentMonthsLimit, }); const merged = mergeWatchHistoryItemsWithFallback( { diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index acf75615..f093c24e 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1015,10 +1015,7 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) { if (!permissionResult.ok) { throw permissionResult.error; } - return nostrClient.resolveWatchHistory(actorKey, { - forceRefresh: true, - recentMonthsLimit: options?.recentMonthsLimit, - }); + return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true }); })() .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) .then(() => { diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 41996431..00c197d7 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -39,7 +39,7 @@ const GRANDFATHERED = { "js/subscriptions.js": 2426, "js/userBlocks.js": 2353, "js/services/nostrService.js": 2227, - "js/nostr/watchHistory.js": 2360, + "js/nostr/watchHistory.js": 2335, "js/nostr/nip46Client.js": 2102, "js/state/cache.js": 1827, "js/app/feedCoordinator.js": 1767, From 915b77b740beb2f42b378f1b9e04c8f97b10f6bb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:57:04 -0400 Subject: [PATCH 23/25] fix(watch-history): stop infinite feed-refresh loop pegging the CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateFingerprintCache (and the fingerprint getter) emitted "fingerprint" unconditionally on every load. The For You feed subscribes to that event and re-runs refreshFeed -> loadLatest -> updateFingerprintCache -> emit, so once the For You feed was active the app entered an infinite loop: a full feed rebuild plus a watch-history reload (with extension decrypts) every ~400ms. That pegged the CPU (fans maxing) and starved every other load — videos, hashtags, subs, DMs and watch history all failed to appear. Emit "fingerprint" only when the fingerprint actually changes. The first real resolution emits once (triggering a single refresh); the follow-up load produces the same fingerprint and emits nothing, so the loop settles immediately. Co-Authored-By: Claude Opus 4.8 --- js/watchHistoryService.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index f093c24e..d13c1f15 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -663,6 +663,7 @@ async function updateFingerprintCache(actorKey, items) { items ); const entry = state.fingerprintCache.get(actorKey) || {}; + const previousFingerprint = entry.fingerprint; const nextEntry = { ...entry, items: Array.isArray(items) ? items : [], @@ -671,7 +672,17 @@ async function updateFingerprintCache(actorKey, items) { }; delete nextEntry.promise; state.fingerprintCache.set(actorKey, nextEntry); - emit("fingerprint", { actor: actorKey, fingerprint, items: nextEntry.items }); + // Only emit when the fingerprint actually CHANGED. Emitting unconditionally + // created an infinite loop: the For You feed listens for "fingerprint" and + // re-runs refreshFeed -> loadLatest -> updateFingerprintCache -> emit, which + // pegged the CPU with a full feed rebuild every ~400ms. + if (fingerprint !== previousFingerprint) { + emit("fingerprint", { + actor: actorKey, + fingerprint, + items: nextEntry.items, + }); + } return fingerprint; } @@ -1337,13 +1348,16 @@ async function getFingerprint(actorInput) { } const fingerprint = await nostrClient.getWatchHistoryFingerprint(actorKey); const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); + const previousFingerprint = cacheEntry?.fingerprint; const nextEntry = { ...(cacheEntry || {}), fingerprint, expiresAt: now + ttl, }; state.fingerprintCache.set(actorKey, nextEntry); - emit("fingerprint", { actor: actorKey, fingerprint }); + if (fingerprint !== previousFingerprint) { + emit("fingerprint", { actor: actorKey, fingerprint }); + } return fingerprint; } From 96da135042491a9274b3f0abaeb380b82f7fa27b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:00:28 -0400 Subject: [PATCH 24/25] test(watch-history): regression test for fingerprint emit-on-change Locks in the fix for the infinite feed-refresh loop: asserts "fingerprint" is emitted only when the value actually changes (unchanged re-evaluation must not emit, or the For You feed would refresh -> reload -> emit forever). Verified to fail when the change-guard is removed. Co-Authored-By: Claude Opus 4.8 --- tests/watchHistoryFingerprintEmit.test.mjs | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/watchHistoryFingerprintEmit.test.mjs diff --git a/tests/watchHistoryFingerprintEmit.test.mjs b/tests/watchHistoryFingerprintEmit.test.mjs new file mode 100644 index 00000000..2c74e535 --- /dev/null +++ b/tests/watchHistoryFingerprintEmit.test.mjs @@ -0,0 +1,73 @@ +// Regression test for the infinite feed-refresh loop that pegged the CPU. +// +// Scenario (SCN-fingerprint-emit-on-change-only): +// Given the For You feed re-runs whenever watch history emits "fingerprint", +// When watch history is re-evaluated but its fingerprint is UNCHANGED, +// Then no "fingerprint" event is emitted — so the feed does not refresh, +// which would re-evaluate watch history, re-emit, and loop forever. +// And when the fingerprint genuinely changes, exactly one event is emitted. +// +// The bug: getFingerprint/updateFingerprintCache emitted "fingerprint" on every +// evaluation regardless of change, so the feed's refresh -> reload -> emit chain +// never settled (a full feed rebuild ~2.5x/second). + +import "./test-helpers/setup-localstorage.mjs"; +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; + +const { nostrClient } = await import("../js/nostrClientFacade.js"); +const { watchHistoryService } = await import("../js/watchHistoryService.js"); +const { WATCH_HISTORY_CACHE_TTL_MS } = await import("../js/config.js"); + +const ACTOR = "a".repeat(64); + +test("emits 'fingerprint' only when it actually changes", async () => { + const originalGet = nostrClient.getWatchHistoryFingerprint; + const originalPubkey = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + + // Logged-in, non-session actor so the feature is enabled for ACTOR. + nostrClient.pubkey = ACTOR; + nostrClient.sessionActor = null; + watchHistoryService.resetProgress(ACTOR); // start from a clean fingerprint cache + + let currentFingerprint = "fp-1"; + nostrClient.getWatchHistoryFingerprint = async () => currentFingerprint; + + let emitCount = 0; + const unsubscribe = watchHistoryService.subscribe("fingerprint", () => { + emitCount += 1; + }); + + // Fake the clock so we can expire the (24h) fingerprint cache between calls + // and thus force a genuine re-evaluation each time (otherwise the fresh-cache + // early-return would mask whether the change-guard works). + mock.timers.enable({ apis: ["Date"] }); + try { + // 1) First evaluation: undefined -> "fp-1" is a change => one emit. + await watchHistoryService.getFingerprint(ACTOR); + assert.equal(emitCount, 1, "first fingerprint should emit once"); + + // 2) Re-evaluate with the SAME fingerprint after the cache expires. + mock.timers.tick(WATCH_HISTORY_CACHE_TTL_MS + 1000); + await watchHistoryService.getFingerprint(ACTOR); + assert.equal( + emitCount, + 1, + "unchanged fingerprint must NOT emit (this is what broke the loop)", + ); + + // 3) Re-evaluate after a real change => exactly one more emit. + mock.timers.tick(WATCH_HISTORY_CACHE_TTL_MS + 1000); + currentFingerprint = "fp-2"; + await watchHistoryService.getFingerprint(ACTOR); + assert.equal(emitCount, 2, "a changed fingerprint should emit once more"); + } finally { + mock.timers.reset(); + unsubscribe(); + nostrClient.getWatchHistoryFingerprint = originalGet; + nostrClient.pubkey = originalPubkey; + nostrClient.sessionActor = originalSession; + watchHistoryService.resetProgress(ACTOR); + } +}); From 255307277084616a824a7ea750c866d30ff4877c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:48:09 -0400 Subject: [PATCH 25/25] fix(dm): keep explicit hints + self-fallback in legacy decrypt ordering The legacy-DM perf reorder regressed tests/nostr/dmDecryptorPreference: 1. preferLegacy short-circuited BEFORE the explicit-hint ranking, so a kind-4 event tagged ["encrypted","nip44"] would still try nip04 first. Explicit hints now win; preferLegacy only flips the *no-hint* default. 2. The "skip the actor's own key" optimization dropped self even when it was the ONLY candidate (note-to-self DMs), leaving zero candidates -> ok:false. Self is now deferred to last and attempted as a fallback instead of dropped. Spec correction (per Dark Factory rules): for a kind-4 DM with no hints, nip04 is now the preferred default (protocol-correct + avoids a wasted serialized nip-07 round-trip). Rewrote the preference test to assert: nip04-first default, explicit nip44 hint wins, nip04->nip44 fallback, and note-to-self decrypts. Co-Authored-By: Claude Opus 4.8 --- js/dmDecryptor.js | 45 ++++++++---- tests/nostr/dmDecryptorPreference.test.mjs | 80 +++++++++++++++++++--- 2 files changed, 104 insertions(+), 21 deletions(-) diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index eff0b928..e4f83cde 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -219,6 +219,7 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLega } const algorithms = Array.isArray(hints?.algorithms) ? hints.algorithms : []; + const hasHints = algorithms.length > 0; const algorithmRanks = new Map(); algorithms.forEach((algorithm, index) => { if (!algorithmRanks.has(algorithm)) { @@ -250,16 +251,20 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLega }; return [...candidates].sort((a, b) => { - // Legacy (kind 4) DMs are always NIP-04 — try nip04 candidates first so we - // don't burn a (serialized, slow) extension round-trip on nip44 first. - if (preferLegacy) { - const aL = a.scheme === "nip04" ? 0 : 1; - const bL = b.scheme === "nip04" ? 0 : 1; - if (aL !== bL) { - return aL - bL; + // Explicit per-event encryption hints (the "encrypted" tag) always win, even + // for legacy kind-4 events: if the sender told us the scheme, honor it. + if (hasHints) { + const aHinted = desiredSchemeRank(a.scheme); + const bHinted = desiredSchemeRank(b.scheme); + if (aHinted !== bHinted) { + return aHinted - bHinted; } } + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + if (preferGiftWrap) { const aWrapScore = a.supportsGiftWrap ? 0 : 1; const bWrapScore = b.supportsGiftWrap ? 0 : 1; @@ -268,8 +273,15 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLega } } - if (a.priority !== b.priority) { - return a.priority - b.priority; + // Legacy (kind 4) default: with no explicit hint, kind-4 DMs are NIP-04, so + // try nip04 first and don't burn a (serialized, slow) nip-07 round-trip on + // nip44. Only flips the default — an explicit hint above already took over. + if (preferLegacy && !hasHints) { + const aL = a.scheme === "nip04" ? 0 : 1; + const bL = b.scheme === "nip04" ? 0 : 1; + if (aL !== bL) { + return aL - bL; + } } const aRank = desiredSchemeRank(a.scheme); @@ -553,18 +565,21 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { const remoteCandidates = []; const seenRemotes = new Set(); + let selfCandidate = null; const registerRemote = (candidate) => { const normalized = normalizeHex(candidate); 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. + seenRemotes.add(normalized); + // A NIP-04 conversation key is derived with the *other* party, so trying the + // actor's own key is normally a wasted (slow, serialized) extension + // round-trip. Defer self to the end so we only attempt it as a fallback — + // but keep it, because note-to-self DMs have no other counterparty. if (normalizedActor && normalized === normalizedActor) { + selfCandidate = normalized; return; } - seenRemotes.add(normalized); remoteCandidates.push(normalized); }; @@ -580,6 +595,10 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { registerRemote(recipient?.pubkey); } + if (selfCandidate) { + remoteCandidates.push(selfCandidate); + } + const attemptDecryption = async (decryptor, remotePubkey) => { try { const plaintext = await decryptor.decrypt(remotePubkey, ciphertext, { diff --git a/tests/nostr/dmDecryptorPreference.test.mjs b/tests/nostr/dmDecryptorPreference.test.mjs index 168d2130..fb304f0c 100644 --- a/tests/nostr/dmDecryptorPreference.test.mjs +++ b/tests/nostr/dmDecryptorPreference.test.mjs @@ -5,14 +5,19 @@ import { decryptDM } from "../../js/dmDecryptor.js"; const buildHex = (value) => value.repeat(64); -const baseEvent = (pubkey) => ({ +const baseEvent = (pubkey, tags = []) => ({ kind: 4, pubkey, content: "ciphertext", - tags: [], + tags, }); -test("decryptDM prefers nip44 decryptors when available", async () => { +// SPEC: a legacy kind-4 DM is NIP-04 by convention. With no explicit encryption +// hint, try nip04 FIRST so we don't burn a slow, serialized nip-07 extension +// round-trip on nip44 before falling back. (Spec correction: the previous +// expectation preferred nip44 by default for kind-4, which caused every legacy +// DM to pay an extra extension call.) +test("decryptDM prefers nip04 first for legacy kind-4 DMs without hints", async () => { const senderPubkey = buildHex("e"); const result = await decryptDM(baseEvent(senderPubkey), { @@ -31,26 +36,59 @@ test("decryptDM prefers nip44 decryptors when available", async () => { ], }); + assert.equal(result.ok, true); + assert.equal(result.scheme, "nip04"); + assert.equal(result.plaintext, "nip04"); +}); + +// SPEC: an explicit per-event encryption hint always wins, even for kind-4. If +// the sender tagged the message nip44, decrypt with nip44 first regardless of +// the legacy default. +test("decryptDM honors an explicit nip44 hint on a kind-4 DM", async () => { + const senderPubkey = buildHex("a"); + + const result = await decryptDM( + baseEvent(senderPubkey, [["encrypted", "nip44"]]), + { + actorPubkey: senderPubkey, + decryptors: [ + { + scheme: "nip04", + decrypt: async () => "nip04", + priority: 0, + }, + { + scheme: "nip44", + decrypt: async () => "nip44", + priority: 0, + }, + ], + }, + ); + assert.equal(result.ok, true); assert.equal(result.scheme, "nip44"); assert.equal(result.plaintext, "nip44"); }); -test("decryptDM falls back to nip04 when nip44 fails", async () => { +// SPEC: when the preferred scheme fails, fall through to the next decryptor and +// still succeed. Here nip04 (tried first by the legacy default) throws, so the +// result must come from nip44. +test("decryptDM falls back to nip44 when the nip04 attempt fails", async () => { const senderPubkey = buildHex("f"); const result = await decryptDM(baseEvent(senderPubkey), { actorPubkey: senderPubkey, decryptors: [ { - scheme: "nip44", + scheme: "nip04", decrypt: async () => { - throw new Error("nip44 failure"); + throw new Error("nip04 failure"); }, priority: 0, }, { - scheme: "nip04", + scheme: "nip44", decrypt: async () => "fallback", priority: 0, }, @@ -58,6 +96,32 @@ test("decryptDM falls back to nip04 when nip44 fails", async () => { }); assert.equal(result.ok, true); - assert.equal(result.scheme, "nip04"); + assert.equal(result.scheme, "nip44"); assert.equal(result.plaintext, "fallback"); }); + +// SPEC: a note-to-self DM has no counterparty other than the actor. The self +// key must still be attempted (deferred to last) rather than dropped, or the +// message can never decrypt. +test("decryptDM still decrypts a note-to-self DM (self is the only candidate)", async () => { + const selfPubkey = buildHex("b"); + let attemptedRemote = null; + + const result = await decryptDM(baseEvent(selfPubkey), { + actorPubkey: selfPubkey, + decryptors: [ + { + scheme: "nip04", + decrypt: async (remotePubkey) => { + attemptedRemote = remotePubkey; + return "self-note"; + }, + priority: 0, + }, + ], + }); + + assert.equal(result.ok, true); + assert.equal(result.plaintext, "self-note"); + assert.equal(attemptedRemote, selfPubkey); +});