mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Per decision: the off-thread signature-verification worker added feed latency (the worker can hang loading nostr-tools, costing up to its timeout on the first batch) and fragility (it could blank the feed) for little benefit on a PUBLIC video feed. Default the VideoEventBuffer verifier to trust-all (the relay pool already does not verify), so feed events commit immediately. Verification is now opt-in via the verifyEvents option (the worker client remains for that). Security note: a malicious relay could serve events with forged authorship for the read feed; verify on sensitive actions instead. Reversible. Co-Authored-By: Claude Opus 4.8 <[email protected]>
270 lines
8.8 KiB
JavaScript
270 lines
8.8 KiB
JavaScript
/**
|
|
* VideoEventBuffer
|
|
*
|
|
* Handles buffering and batch processing of incoming video events
|
|
* to prevent UI thrashing during high-volume updates (e.g. initial load).
|
|
*/
|
|
|
|
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, 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 = [];
|
|
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pushes a raw event into the buffer and schedules a debounced flush.
|
|
* @param {import("nostr-tools").Event} event - The incoming Nostr event.
|
|
*/
|
|
push(event) {
|
|
this.buffer.push(event);
|
|
this.scheduleFlush(false);
|
|
}
|
|
|
|
/**
|
|
* Schedules processing of the buffer.
|
|
* Uses a debounce timer to wait for the stream to pause (pressure valve).
|
|
*
|
|
* @param {boolean} [immediate=false] - If true, flushes immediately (e.g. on EOSE).
|
|
*/
|
|
scheduleFlush(immediate = false) {
|
|
if (this.flushTimerId) {
|
|
if (!immediate) {
|
|
return this._flushPromise;
|
|
}
|
|
clearTimeout(this.flushTimerId);
|
|
this.flushTimerId = null;
|
|
}
|
|
|
|
if (immediate) {
|
|
return this.flush();
|
|
}
|
|
|
|
this.flushTimerId = setTimeout(() => {
|
|
this.flushTimerId = null;
|
|
this.flush();
|
|
}, this.FLUSH_DEBOUNCE_MS);
|
|
return this._flushPromise;
|
|
}
|
|
|
|
/**
|
|
* Processes all buffered events in a single batch.
|
|
*
|
|
* **Steps:**
|
|
* 1. Validates and converts raw events to Video objects.
|
|
* 2. Checks for tombstones (deleted videos).
|
|
* 3. Updates the client's `activeMap` (Latest Wins).
|
|
* 4. Triggers the UI callback *once*.
|
|
* 5. Triggers background hydration of NIP-71 metadata.
|
|
* 6. Persists state to IndexedDB.
|
|
*/
|
|
flush() {
|
|
if (!this.buffer.length) {
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
if (evt && evt.id) {
|
|
this.client.rawEvents.set(evt.id, evt);
|
|
}
|
|
const video = convertEventToVideo(evt);
|
|
|
|
if (video.invalid) {
|
|
this.invalidEvents.push({ id: video.id, reason: video.reason });
|
|
continue;
|
|
}
|
|
|
|
// Merge any NIP-71 metadata we might already have cached for this video
|
|
this.client.mergeNip71MetadataIntoVideo(video);
|
|
// Determine the "true" creation time of the root video
|
|
this.client.applyRootCreatedAt(video);
|
|
|
|
const activeKey = this.client.getActiveKey(video);
|
|
const wasDeletedEvent = video.deleted === true;
|
|
|
|
// If this is a deletion event (Kind 5 or deletion marker), record a tombstone
|
|
// to prevent older versions from resurrecting.
|
|
if (wasDeletedEvent) {
|
|
this.client.recordTombstone(activeKey, video.created_at);
|
|
} else {
|
|
// Otherwise, check if this video is already known to be deleted
|
|
this.client.applyTombstoneGuard(video);
|
|
}
|
|
|
|
// Store in allEvents (history preservation)
|
|
this.client.allEvents.set(evt.id, video);
|
|
this.client.dirtyEventIds.add(evt.id);
|
|
|
|
// If it's a "deleted" note, remove from activeMap
|
|
if (video.deleted) {
|
|
if (activeKey) {
|
|
if (wasDeletedEvent) {
|
|
this.client.activeMap.delete(activeKey);
|
|
} else {
|
|
const currentActive = this.client.activeMap.get(activeKey);
|
|
if (currentActive?.id === video.id) {
|
|
this.client.activeMap.delete(activeKey);
|
|
}
|
|
}
|
|
}
|
|
updatedVideos.push(video);
|
|
continue;
|
|
}
|
|
|
|
// LATEST-WINS LOGIC
|
|
// We only update the UI if the incoming video is newer than what we have.
|
|
// This handles the "Edit" case where multiple versions exist on relays.
|
|
const prevActive = this.client.activeMap.get(activeKey);
|
|
if (!prevActive || video.created_at > prevActive.created_at) {
|
|
this.client.activeMap.set(activeKey, video);
|
|
updatedVideos.push(video);
|
|
}
|
|
} catch (err) {
|
|
devLogger.error("[VideoEventBuffer] Error processing event:", err);
|
|
}
|
|
}
|
|
|
|
if (updatedVideos.length > 0) {
|
|
if (typeof document !== "undefined" && document.hidden) {
|
|
this.pendingVideos.push(...updatedVideos);
|
|
} else {
|
|
// Trigger the callback once per batch to avoid UI thrashing
|
|
this.onVideo(updatedVideos);
|
|
}
|
|
|
|
// Fetch NIP-71 metadata (categorization tags) in the background for the whole batch
|
|
this.client.populateNip71MetadataForVideos(updatedVideos)
|
|
.then(() => {
|
|
for (const video of updatedVideos) {
|
|
this.client.applyRootCreatedAt(video);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
devLogger.warn(
|
|
"[nostr] Failed to hydrate NIP-71 metadata for live video batch:",
|
|
error
|
|
);
|
|
});
|
|
}
|
|
|
|
// Persist processed events after each flush so reloads warm quickly.
|
|
this.client.saveLocalData("subscribeVideos:flush");
|
|
}
|
|
|
|
handleVisibilityChange() {
|
|
if (typeof document !== "undefined" && !document.hidden) {
|
|
if (this.pendingVideos.length > 0) {
|
|
const batch = this.pendingVideos;
|
|
this.pendingVideos = [];
|
|
this.onVideo(batch);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when the subscription reaches End-of-Stored-Events (EOSE).
|
|
* Triggers an immediate flush to ensure the UI is fully up-to-date.
|
|
*/
|
|
handleEose() {
|
|
if (isDevMode && this.invalidEvents.length > 0) {
|
|
userLogger.warn(
|
|
`[subscribeVideos] found ${this.invalidEvents.length} invalid video notes (with reasons):`,
|
|
this.invalidEvents
|
|
);
|
|
}
|
|
devLogger.log(
|
|
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
|
|
);
|
|
return this.scheduleFlush(true);
|
|
}
|
|
|
|
/**
|
|
* Cleans up timers and flushes any remaining events.
|
|
* Call when unsubscribing.
|
|
*/
|
|
cleanup() {
|
|
if (this.flushTimerId) {
|
|
clearTimeout(this.flushTimerId);
|
|
this.flushTimerId = null;
|
|
}
|
|
if (typeof document !== "undefined") {
|
|
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
}
|
|
// Ensure any straggling events are flushed before tearing down.
|
|
return this.flush();
|
|
}
|
|
}
|