fix(nostr): serialize nip-07 requests to prevent extension timeouts and crashes

Introduced a request queue in `js/nostr/nip07Permissions.js` to serialize all interactions with the NIP-07 extension (`window.nostr`).
Updated `js/userBlocks.js`, `js/services/hashtagPreferencesService.js`, and `js/auth/signingAdapter.js` to wrap direct extension calls with `runNip07WithRetry`, ensuring they participate in the queue.
This resolves "message channel closed" errors and timeouts caused by concurrent requests flooding the extension during sync operations.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-01-28 14:35:56 +00:00
1 parent dc7257855a
commit 498909d60e
4 files changed
+130 -46

No files matched your search

+35 -9
View File
@@ -3,6 +3,7 @@ import { signEventWithPrivateKey } from "../nostr/publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js";
import { normalizeActorKey } from "../nostr/watchHistory.js";
import { buildHttpAuthEvent } from "../nostrEventSchemas.js";
import { runNip07WithRetry } from "../nostr/nip07Permissions.js";
const DEFAULT_TEST_PUBKEY = "f".repeat(64);
const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer";
@@ -31,22 +32,37 @@ export function createNip07SigningAdapter({ extension } = {}) {
(typeof window !== "undefined" && window?.nostr ? window.nostr : null);
const getPubkey = async () => {
if (!resolvedExtension || typeof resolvedExtension.getPublicKey !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.getPublicKey !== "function"
) {
throw new Error("NIP-07 extension is unavailable.");
}
return normalizePubkey(await resolvedExtension.getPublicKey());
return normalizePubkey(
await runNip07WithRetry(() => resolvedExtension.getPublicKey(), {
label: "extension.getPublicKey",
}),
);
};
const getDisplayName = async () => {
if (!resolvedExtension || typeof resolvedExtension.getMetadata !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.getMetadata !== "function"
) {
return "";
}
const metadata = await resolvedExtension.getMetadata();
const metadata = await runNip07WithRetry(
() => resolvedExtension.getMetadata(),
{ label: "extension.getMetadata" },
);
if (!metadata || typeof metadata !== "object") {
return "";
}
const displayName =
typeof metadata.display_name === "string" ? metadata.display_name.trim() : "";
typeof metadata.display_name === "string"
? metadata.display_name.trim()
: "";
if (displayName) {
return displayName;
}
@@ -54,17 +70,27 @@ export function createNip07SigningAdapter({ extension } = {}) {
};
const signEvent = async (event) => {
if (!resolvedExtension || typeof resolvedExtension.signEvent !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.signEvent !== "function"
) {
throw new Error("NIP-07 extension is missing signEvent.");
}
return resolvedExtension.signEvent(event);
return runNip07WithRetry(() => resolvedExtension.signEvent(event), {
label: "extension.signEvent",
});
};
const signMessage = async (message) => {
if (!resolvedExtension || typeof resolvedExtension.signMessage !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.signMessage !== "function"
) {
throw new Error("NIP-07 extension does not support signMessage.");
}
return resolvedExtension.signMessage(message);
return runNip07WithRetry(() => resolvedExtension.signMessage(message), {
label: "extension.signMessage",
});
};
return {
+53 -27
View File
@@ -30,6 +30,11 @@ export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
...DEFAULT_NIP07_ENCRYPTION_METHODS,
]);
// Module-level mutex to serialize NIP-07 extension requests.
// This prevents "message channel closed" errors caused by race conditions
// when multiple components (blocks, DMs, auth) query the extension simultaneously.
let nip07Queue = Promise.resolve();
export function getEnableVariantTimeoutMs() {
const overrideValue =
typeof globalThis !== "undefined" &&
@@ -192,40 +197,61 @@ export async function runNip07WithRetry(
retryMultiplier = 2,
} = {},
) {
// We wrap the operation invocation to ensure it returns a fresh promise each time
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
const invokeOperation = () => Promise.resolve(operation());
// We wrap the entire retry logic in a queue task to ensure only one
// request hits the extension at a time.
const executeTask = async () => {
// We wrap the operation invocation to ensure it returns a fresh promise each time
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
const invokeOperation = () => Promise.resolve(operation());
try {
return await withNip07Timeout(invokeOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
try {
return await withNip07Timeout(invokeOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
if (!isTimeoutError || retryMultiplier <= 1) {
throw error;
if (!isTimeoutError || retryMultiplier <= 1) {
throw error;
}
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
devLogger.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
);
// On retry, we invoke operation() again to send a fresh request to the extension.
return withNip07Timeout(invokeOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
}
};
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
const queueItem = nip07Queue.then(() =>
executeTask().then(
(res) => ({ ok: true, value: res }),
(err) => ({ ok: false, error: err }),
),
);
devLogger.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
);
// Advance the queue pointer
nip07Queue = queueItem.then(() => {});
// On retry, we invoke operation() again to send a fresh request to the extension.
return withNip07Timeout(invokeOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
// Await the specific result for this call
const result = await queueItem;
if (result.ok) {
return result.value;
}
throw result.error;
}
export async function requestEnablePermissions(
+18 -4
View File
@@ -19,6 +19,7 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { profileCache } from "../state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js";
import { relayManager } from "../relayManager.js";
import { runNip07WithRetry } from "../nostr/nip07Permissions.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
@@ -761,7 +762,11 @@ class HashtagPreferencesService {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(userPubkey, payload),
{ label: "nip04.decrypt" },
),
"extension",
);
}
@@ -774,7 +779,10 @@ class HashtagPreferencesService {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(() => nip44.decrypt(userPubkey, payload), {
label: "nip44.decrypt",
}),
"extension",
);
}
@@ -784,13 +792,19 @@ class HashtagPreferencesService {
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(() => nip44v2.decrypt(userPubkey, payload), {
label: "nip44.v2.decrypt",
}),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(() => nip44v2.decrypt(userPubkey, payload), {
label: "nip44.v2.decrypt",
}),
"extension",
);
}
+24 -6
View File
@@ -20,6 +20,7 @@ import {
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
import { runNip07WithRetry } from "./nostr/nip07Permissions.js";
class TinyEventEmitter {
constructor() {
@@ -874,33 +875,50 @@ class UserBlockListManager {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(normalized, payload),
{ label: "nip04.decrypt" },
),
"extension",
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
? nostrApi.nip44
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(() => nip44.decrypt(normalized, payload), {
label: "nip44.decrypt",
}),
"extension",
);
}
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(() => nip44v2.decrypt(normalized, payload), {
label: "nip44.v2.decrypt",
}),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(normalized, payload),
{ label: "nip44.v2.decrypt" },
),
"extension",
);
}