Merge pull request #2785 from PR0M3TH3AN/perf/login-feed-reliability

perf(feed): fix nip-07 watch-history/DM/hashtag loop starving the For You feed
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-06-13 19:00:26 -04:00
commit 5d67088754
35 files changed
+2435 -110

No files matched your search

+8
View File
@@ -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;
+5
View File
@@ -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,
]);
+9
View File
@@ -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.
*/
+12
View File
@@ -10,6 +10,8 @@
*/
import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js";
import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js";
import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js";
import { FEED_TYPES } from "../constants.js";
/**
@@ -489,6 +491,14 @@ export function createAuthSessionCoordinator(deps) {
}
const taskOutcomes = await Promise.all(parallelListTasks);
// 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),
);
@@ -881,6 +891,8 @@ export function createAuthSessionCoordinator(deps) {
this.resetHashtagPreferencesState();
this.resetPermissionPromptState();
clearDecryptionSchemeCache();
clearWatchHistoryConversationKeyCache();
clearWatchHistoryDecryptedChunkCache();
this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" });
try {
+39
View File
@@ -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;
+61 -25
View File
@@ -213,12 +213,13 @@ function ensureDecryptCandidates(rawCandidates) {
.filter(Boolean);
}
function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLegacy = false } = {}) {
if (!candidates.length) {
return [];
}
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,14 +251,18 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
};
return [...candidates].sort((a, b) => {
if (a.priority !== b.priority) {
return a.priority - b.priority;
// 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;
}
}
const aRank = desiredSchemeRank(a.scheme);
const bRank = desiredSchemeRank(b.scheme);
if (aRank !== bRank) {
return aRank - bRank;
if (a.priority !== b.priority) {
return a.priority - b.priority;
}
if (preferGiftWrap) {
@@ -268,6 +273,23 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
}
}
// 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);
const bRank = desiredSchemeRank(b.scheme);
if (aRank !== bRank) {
return aRank - bRank;
}
return 0;
});
}
@@ -538,17 +560,26 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
});
}
const ordered = orderDecryptors(decryptors, hints);
const ordered = orderDecryptors(decryptors, hints, { preferLegacy: true });
const errors = [];
const remoteCandidates = [];
const seenRemotes = new Set();
let selfCandidate = null;
const registerRemote = (candidate) => {
const normalized = normalizeHex(candidate);
if (!normalized || seenRemotes.has(normalized)) {
return;
}
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;
}
remoteCandidates.push(normalized);
};
@@ -564,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, {
@@ -604,28 +639,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 = {}) {
+88 -22
View File
@@ -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 = [];
@@ -569,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;
@@ -1140,6 +1150,7 @@ export class NostrClient {
if (this.dmDecryptCache) {
this.dmDecryptCache.clear();
}
dmPlaintextCache.clear();
}
async ensureDmDecryptor() {
@@ -1350,27 +1361,67 @@ 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
}
}
// 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;
}
}
const decryptDM = await this.ensureDmDecryptor();
const context = await this.buildDmDecryptContext(actorPubkey);
const decryptPromise = (async () => {
const decryptDM = await this.ensureDmDecryptor();
const context = await this.buildDmDecryptContext(actorPubkey);
let result;
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 {
result = await decryptDM(event, context);
} catch (error) {
devLogger.warn("[nostr] DM decryptor threw unexpectedly.", {
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
throw error;
return await decryptPromise;
} finally {
if (eventId) {
this.dmDecryptInFlight.delete(eventId);
}
}
if (result?.ok && eventId) {
this.dmDecryptCache.set(eventId, result);
}
return result;
}
/**
@@ -1436,6 +1487,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);
};
@@ -3321,23 +3374,36 @@ 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
// 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 =
+7 -1
View File
@@ -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);
+97
View File
@@ -0,0 +1,97 @@
// Shared factory for a persisted "decrypted plaintext" cache keyed by event id.
//
// Several nostr surfaces (watch-history chunks, direct messages) decrypt many
// immutable events, each of which is a serialized round-trip to the nip-07
// extension (the private key lives in the extension and cannot be cached
// locally). Caching the decrypted plaintext by event id means reloads decrypt
// only events we haven't seen before, skipping the extension for the rest —
// across sessions.
//
// The cached plaintext is no more sensitive than the data each surface already
// persists (watch-history items; DM previews). Callers must clear the cache on
// logout so a decrypted secret never outlives its session.
//
// @param {string} storageKey localStorage key for this cache.
// @param {number} [maxEntries] cap on retained entries (most-recent kept).
export function createPersistedPlaintextCache(storageKey, maxEntries = 400) {
/** @type {Map<string, string> | null} */
let cache = null;
let dirty = false;
function ensure() {
if (cache) {
return cache;
}
cache = new Map();
try {
if (typeof localStorage !== "undefined") {
const raw = localStorage.getItem(storageKey);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
cache = new Map(parsed);
}
}
}
} catch (_) {
cache = new Map();
}
return cache;
}
function get(eventId) {
if (!eventId) {
return null;
}
const c = ensure();
return c.has(eventId) ? c.get(eventId) : null;
}
function set(eventId, plaintext) {
if (!eventId || typeof plaintext !== "string" || !plaintext) {
return;
}
const c = ensure();
if (c.get(eventId) === plaintext) {
return;
}
c.set(eventId, plaintext);
dirty = true;
}
// Persist pending writes once (call after a batch of decrypts). Bounds size.
function flush() {
if (!dirty) {
return;
}
dirty = false;
try {
if (typeof localStorage === "undefined") {
return;
}
let entries = Array.from(cache.entries());
if (entries.length > maxEntries) {
entries = entries.slice(entries.length - maxEntries);
cache = new Map(entries);
}
localStorage.setItem(storageKey, JSON.stringify(entries));
} catch (_) {
// ignore persistence failures (quota/availability)
}
}
function clear() {
cache = new Map();
dirty = false;
try {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(storageKey);
}
} catch (_) {
// ignore
}
}
return { get, set, flush, clear };
}
+56
View File
@@ -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<NostrEvent> }
// 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" },
});
}
});
+165
View File
@@ -0,0 +1,165 @@
// 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";
// 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();
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);
disableWorker();
for (const entry of pending.values()) {
clearTimeout(entry.timeoutId);
entry.reject(error instanceof Error ? error : new Error("verify-worker-error"));
}
pending.clear();
});
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 {
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) {
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") {
// 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) {
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<object>} events
* @param {{ timeoutMs?: number }} [options]
* @returns {Promise<Set<string>>}
*/
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);
// 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 });
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 };
+56 -6
View File
@@ -9,18 +9,34 @@ import { isDevMode } from "../config.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { convertEventToVideo } from "./nip71.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<Set<string>> }} [options]
* Optional signature verifier (returns the set of valid event ids). Defaults
* to trusting relays (no verification) for the public feed.
*/
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
: trustAllVerifier;
// 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 +64,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 +99,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 +248,7 @@ export class VideoEventBuffer {
devLogger.log(
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
);
this.scheduleFlush(true);
return this.scheduleFlush(true);
}
/**
@@ -214,6 +264,6 @@ export class VideoEventBuffer {
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
}
// Ensure any straggling events are flushed before tearing down.
this.flush();
return this.flush();
}
}
+58 -13
View File
@@ -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 };
@@ -483,6 +488,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<string, unknown>}
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 +540,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 +570,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;
};
@@ -2019,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(
{
@@ -2031,6 +2064,9 @@ class WatchHistoryManager {
},
toolkitCacheRef,
);
if (plaintext) {
setCachedChunkPlaintext(event.id, plaintext);
}
}
if (!plaintext) {
return [];
@@ -2043,6 +2079,7 @@ class WatchHistoryManager {
},
{ concurrency: 5 },
);
flushDecryptedChunkCache();
decryptedItems.push(...chunkResults.flat());
} catch (error) {
devLogger.warn("[nostr] Failed to fetch watch history chunks:", error);
@@ -2068,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;
}
@@ -2329,4 +2373,5 @@ export const watchHistoryHelpers = {
serializeWatchHistoryItems,
computeWatchHistoryFingerprintForItems,
extractPointerItemsFromEvent,
createNip44CipherSuite,
};
+14
View File
@@ -0,0 +1,14 @@
// Persisted cache of decrypted watch-history chunk plaintext, keyed by the
// 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.
import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js";
const cache = createPersistedPlaintextCache("bitvid:whDecryptedChunks:v1", 400);
export const getCachedChunkPlaintext = cache.get;
export const setCachedChunkPlaintext = cache.set;
export const flushDecryptedChunkCache = cache.flush;
export const clearWatchHistoryDecryptedChunkCache = cache.clear;
+9 -9
View File
@@ -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);
}
}
+2
View File
@@ -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,
+33 -6
View File
@@ -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;
}
@@ -1278,7 +1289,7 @@ async function loadLatest(actorInput, options = {}) {
return mergeQueuedItemsIfNeeded(actorKey, resolved);
}
if (!allowStale || !hasCachedItems) {
if (!allowStale) {
const resolved = await scheduleWatchHistoryRefresh(
actorKey,
cacheEntry,
@@ -1287,6 +1298,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,
@@ -1294,15 +1312,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 stale watch list items while refresh is pending.",
"[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) {
@@ -1324,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;
}
+44
View File
@@ -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();
+137
View File
@@ -0,0 +1,137 @@
// 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 = {};
function resetExtCalls() {
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);
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 {
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(`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();
if (typeof relay.close === "function") await relay.close();
process.exit(0);
}
main().catch((e) => { console.error("dm-repro failed:", e); process.exit(1); });
+138
View File
@@ -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);
});
+222
View File
@@ -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); });
+212
View File
@@ -0,0 +1,212 @@
// 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 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.
//
// 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 WH_ITEMS=120 HEADLESS=1
import { chromium } from "playwright";
import { startRelay } from "../agent/simple-relay.mjs";
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 USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex"));
const USER_PK = getPublicKey(USER_SK);
// --- Extension call accounting + serialized latency queue ---
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();
};
const p = queue.then(run, run);
queue = p.catch(() => {});
return p;
}
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 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,
),
);
const now = Math.floor(Date.now() / 1000);
const videoIds = [];
for (let i = 0; i < VIDEOS; i++) {
const dTag = `seed-${i}`;
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=${USER_PK.slice(0, 12)}…`);
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
// 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))));
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: () => 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 testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`;
// 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?.isLoggedIn === true;
}, { timeout: 15000 }).catch(() => {});
return { page, t0, logs };
}
// --- Session 1: log in, publish a watch history, screenshot. ---
const s1 = await session("seed");
let firstCardMs = null;
try {
await s1.page.waitForSelector("[data-video-id]", { timeout: 15000 });
firstCardMs = Date.now() - s1.t0;
} catch (_) {}
await s1.page.screenshot({ path: `${SHOT_DIR}/03-after-login.png` });
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();
process.exit(0);
}
main().catch((e) => {
console.error("ux-harness failed:", e);
process.exit(1);
});
+129
View File
@@ -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); });
@@ -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();
}
});
+7 -3
View File
@@ -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(
@@ -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);
});
+4
View File
@@ -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(() => {
+72 -8
View File
@@ -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);
});
@@ -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);
});
});
@@ -0,0 +1,108 @@
// 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) {
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(() => {
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.terminated += 1;
}
}
FakeWorker.instances = [];
FakeWorker.validIds = null;
FakeWorker.silent = false;
FakeWorker.terminated = 0;
describe("signatureVerifyWorkerClient", () => {
let verifyEventsInWorker;
beforeEach(async () => {
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;
});
afterEach(() => {
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" },
{ 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)));
});
});
@@ -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",
);
});
});
+25 -17
View File
@@ -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);
@@ -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");
});
@@ -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");
});
@@ -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);
}
});