diff --git a/js/nostr/client.js b/js/nostr/client.js index b9f9c160..9b45e4aa 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -173,6 +173,7 @@ import { import { createNip07Adapter } from "./adapters/nip07Adapter.js"; import { createNsecAdapter } from "./adapters/nsecAdapter.js"; import { createNip46Adapter } from "./adapters/nip46Adapter.js"; +import { queueSignEvent } from "./signRequestQueue.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -4484,7 +4485,7 @@ export class NostrClient { let signedEvent; try { - signedEvent = await signer.signEvent(event); + signedEvent = await queueSignEvent(signer, event); } catch (error) { return { ok: false, error: "signature-failed", details: error }; } @@ -5065,7 +5066,7 @@ export class NostrClient { } try { - const signedEvent = await signer.signEvent(event); + const signedEvent = await queueSignEvent(signer, event); devLogger.log("Signed edited event:", signedEvent); const publishResults = await publishEventToRelays( @@ -5238,7 +5239,7 @@ export class NostrClient { } } - const signedEvent = await signer.signEvent(event); + const signedEvent = await queueSignEvent(signer, event); const publishResults = await publishEventToRelays( this.pool, this.relays, @@ -5551,7 +5552,7 @@ export class NostrClient { : "Delete published video events", }; - const signedDelete = await signer.signEvent(deleteEvent); + const signedDelete = await queueSignEvent(signer, deleteEvent); const publishResults = await publishEventToRelays( this.pool, this.relays, diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index e2f02078..ab87cdfe 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -11,6 +11,7 @@ import { devLogger, userLogger } from "../utils/logger.js"; import { LRUCache } from "../utils/lruCache.js"; import { CACHE_POLICIES } from "./cachePolicies.js"; import { isSessionActor } from "./sessionActor.js"; +import { queueSignEvent } from "./signRequestQueue.js"; const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT]; @@ -1221,7 +1222,9 @@ export async function publishComment( } try { - signedEvent = await signer.signEvent(event); + signedEvent = await queueSignEvent(signer, event, { + timeoutMs: options?.timeoutMs, + }); } catch (error) { userLogger.warn( "[nostr] Failed to sign comment event with active signer:", diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 44da23d8..429fcd10 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -19,6 +19,7 @@ import { import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { logRebroadcastCountFailure } from "./countDiagnostics.js"; +import { queueSignEvent } from "./signRequestQueue.js"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; const rebroadcastAttemptMemory = new Map(); @@ -359,7 +360,9 @@ export async function signAndPublishEvent({ } if (permissionResult.ok) { try { - signedEvent = await signer.signEvent(event); + signedEvent = await queueSignEvent(signer, event, { + timeoutMs: options.timeoutMs, + }); } catch (error) { userLogger.warn( "[nostr] Failed to sign event with active signer:", diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js index 341d296c..d059f2cc 100644 --- a/js/nostr/reactionEvents.js +++ b/js/nostr/reactionEvents.js @@ -6,6 +6,7 @@ import { devLogger, userLogger } from "../utils/logger.js"; import { LRUCache } from "../utils/lruCache.js"; import { CACHE_POLICIES } from "./cachePolicies.js"; import { NOTE_TYPES } from "../nostrEventSchemas.js"; +import { queueSignEvent } from "./signRequestQueue.js"; const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_REACTION]; const reactionCache = new LRUCache({ maxSize: 100 }); @@ -441,7 +442,9 @@ export async function publishVideoReaction( } if (permissionResult.ok) { try { - signedEvent = await signer.signEvent(event); + signedEvent = await queueSignEvent(signer, event, { + timeoutMs: options?.timeoutMs, + }); } catch (error) { userLogger.warn( "[nostr] Failed to sign reaction event with active signer:", diff --git a/js/nostr/signRequestQueue.js b/js/nostr/signRequestQueue.js new file mode 100644 index 00000000..f4c658c0 --- /dev/null +++ b/js/nostr/signRequestQueue.js @@ -0,0 +1,130 @@ +import { devLogger } from "../utils/logger.js"; + +const DEFAULT_SIGN_TIMEOUT_MS = 20_000; +const signQueues = new WeakMap(); + +const SIGN_ERROR_CODES = new Set([ + "permission-denied", + "timeout", + "not-capable", + "signer-disconnected", +]); + +function resolveTimeoutMs(timeoutMs) { + const candidate = Number(timeoutMs); + if (Number.isFinite(candidate) && candidate > 0) { + return Math.floor(candidate); + } + return DEFAULT_SIGN_TIMEOUT_MS; +} + +function toError(value, message) { + if (value instanceof Error) { + return value; + } + const error = new Error(message); + error.details = value; + return error; +} + +function normalizeSignError(error) { + const normalized = toError(error, "signEvent failed"); + const existingCode = typeof normalized.code === "string" ? normalized.code : ""; + if (SIGN_ERROR_CODES.has(existingCode)) { + return normalized; + } + + const message = typeof normalized.message === "string" + ? normalized.message.toLowerCase() + : ""; + + if ( + existingCode === "extension-permission-denied" || + message.includes("permission") || + message.includes("denied") || + message.includes("rejected") + ) { + normalized.code = "permission-denied"; + return normalized; + } + + if ( + existingCode === "nostr-extension-missing" || + message.includes("disconnected") || + message.includes("not connected") || + message.includes("connection closed") || + message.includes("extension missing") + ) { + normalized.code = "signer-disconnected"; + return normalized; + } + + if (existingCode === "sign-event-unavailable") { + normalized.code = "not-capable"; + return normalized; + } + + return normalized; +} + +function createSignError(code, originalError) { + const error = toError( + originalError, + originalError?.message || `signEvent failed with ${code}`, + ); + error.code = code; + if (originalError && originalError !== error) { + error.details = originalError; + } + return error; +} + +function runWithTimeout(operation, timeoutMs) { + const resolvedTimeout = resolveTimeoutMs(timeoutMs); + let timeoutId; + let timedOut = false; + const signPromise = Promise.resolve().then(operation); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timedOut = true; + reject(createSignError("timeout", new Error( + `signEvent timed out after ${resolvedTimeout}ms`, + ))); + }, resolvedTimeout); + }); + + return Promise.race([signPromise, timeoutPromise]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (timedOut) { + void signPromise.catch((error) => { + devLogger.warn("[nostr] signEvent resolved after timeout:", error); + }); + } + }); +} + +export async function queueSignEvent(signer, event, options = {}) { + if (!signer || typeof signer !== "object") { + throw createSignError("not-capable", new Error("signer-unavailable")); + } + + if (typeof signer.signEvent !== "function") { + throw createSignError("not-capable", new Error("sign-event-unavailable")); + } + + const queue = signQueues.get(signer) || Promise.resolve(); + const runSign = async () => runWithTimeout( + () => signer.signEvent(event), + options.timeoutMs, + ); + const queued = queue.then(runSign, runSign); + signQueues.set(signer, queued.catch(() => {})); + + try { + return await queued; + } catch (error) { + throw normalizeSignError(error); + } +} diff --git a/js/nostr/viewEvents.js b/js/nostr/viewEvents.js index 4a1b4068..284e5d92 100644 --- a/js/nostr/viewEvents.js +++ b/js/nostr/viewEvents.js @@ -13,6 +13,7 @@ import { RELAY_URLS } from "./toolkit.js"; import { normalizePointerInput } from "./watchHistory.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { logViewCountFailure } from "./countDiagnostics.js"; +import { queueSignEvent } from "./signRequestQueue.js"; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; @@ -793,7 +794,9 @@ export async function publishViewEvent( } if (permissionResult.ok) { try { - signedEvent = await signer.signEvent(event); + signedEvent = await queueSignEvent(signer, event, { + timeoutMs: options?.timeoutMs, + }); } catch (error) { userLogger.warn( "[nostr] Failed to sign view event with active signer:", diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index f3f5a1e2..6a1bd2d1 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -23,6 +23,7 @@ import { import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { profileCache } from "../state/profileCache.js"; +import { queueSignEvent } from "./signRequestQueue.js"; /** * Domain utilities for watch-history interactions. This module owns pointer @@ -1452,7 +1453,9 @@ class WatchHistoryManager { const signEvent = async (event) => { if (activeSigner) { - return activeSigner.signEvent(event); + return queueSignEvent(activeSigner, event, { + timeoutMs: options.timeoutMs, + }); } return this.deps.signEventWithPrivateKey(event, privateKey); };