mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-04 03:42:26 +00:00
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:
35 files changed
+2435
-110
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user