mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor relay publish acknowledgements
This commit is contained in:
1 parent
9aa5a360d6
commit
92cc05f1f6
6 files changed
+815
-172
No files matched your search
+185
-131
@@ -28,6 +28,11 @@ import {
|
||||
buildWatchHistoryIndexEvent,
|
||||
buildWatchHistoryChunkEvent,
|
||||
} from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelay,
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
/**
|
||||
* The default relay set BitVid bootstraps with before loading a user's
|
||||
@@ -1139,92 +1144,6 @@ function decodeNpubToHex(npub) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const DM_PUBLISH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function publishEventToRelay(pool, url, event) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finalize = (success, error = null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({ url, success, error });
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
finalize(false, new Error("publish timeout"));
|
||||
}, DM_PUBLISH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const pub = pool.publish([url], event);
|
||||
|
||||
if (pub && typeof pub.on === "function") {
|
||||
const registerHandler = (eventName, handler) => {
|
||||
try {
|
||||
pub.on(eventName, handler);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[nostr] Relay publish handle rejected ${eventName} listener:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
};
|
||||
|
||||
const handleFailure = (reason) => {
|
||||
clearTimeout(timeoutId);
|
||||
const err =
|
||||
reason instanceof Error
|
||||
? reason
|
||||
: new Error(String(reason || "publish failed"));
|
||||
finalize(false, err);
|
||||
};
|
||||
|
||||
let handlerRegistered = false;
|
||||
handlerRegistered =
|
||||
registerHandler("ok", handleSuccess) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("seen", handleSuccess) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("failed", handleFailure) || handlerRegistered;
|
||||
|
||||
if (handlerRegistered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pub && typeof pub.then === "function") {
|
||||
pub
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
});
|
||||
}
|
||||
|
||||
const EXTENSION_MIME_MAP = {
|
||||
mp4: "video/mp4",
|
||||
m4v: "video/x-m4v",
|
||||
@@ -5130,17 +5049,54 @@ class NostrClient {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) console.log("Signed event:", signedEvent);
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) console.log(`Video published to ${url}`);
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
|
||||
}
|
||||
})
|
||||
const publishResults = await publishEventToRelays(
|
||||
this.pool,
|
||||
this.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "video note",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[nostr] Video note rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
const { accepted: acceptedRelays, failed: failedRelays } = publishSummary;
|
||||
|
||||
if (isDevMode) {
|
||||
acceptedRelays.forEach(({ url }) =>
|
||||
console.log(`Video published to ${url}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (failedRelays.length) {
|
||||
failedRelays.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[nostr] Video note not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (finalUrl) {
|
||||
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
|
||||
const mimeType =
|
||||
@@ -5181,23 +5137,54 @@ class NostrClient {
|
||||
console.log("Signed NIP-94 mirror event:", signedMirrorEvent);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedMirrorEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`NIP-94 mirror published to ${url}`);
|
||||
}
|
||||
} catch (mirrorErr) {
|
||||
if (isDevMode) {
|
||||
console.error(
|
||||
`Failed to publish NIP-94 mirror to ${url}`,
|
||||
mirrorErr
|
||||
const mirrorResults = await publishEventToRelays(
|
||||
this.pool,
|
||||
this.relays,
|
||||
signedMirrorEvent
|
||||
);
|
||||
|
||||
try {
|
||||
const mirrorSummary = assertAnyRelayAccepted(mirrorResults, {
|
||||
context: "NIP-94 mirror",
|
||||
});
|
||||
|
||||
if (isDevMode) {
|
||||
mirrorSummary.accepted.forEach(({ url }) =>
|
||||
console.log(`NIP-94 mirror published to ${url}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (mirrorSummary.failed.length) {
|
||||
mirrorSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[nostr] NIP-94 mirror not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (mirrorError) {
|
||||
if (mirrorError?.relayFailures?.length) {
|
||||
mirrorError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.warn(
|
||||
`[nostr] NIP-94 mirror rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
);
|
||||
} else if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] NIP-94 mirror rejected by all relays:",
|
||||
mirrorError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
@@ -5356,21 +5343,52 @@ class NostrClient {
|
||||
console.log("Signed edited event:", signedEvent);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`Edited video published to ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(`Publish failed to ${url}`, err);
|
||||
}
|
||||
}
|
||||
})
|
||||
const publishResults = await publishEventToRelays(
|
||||
this.pool,
|
||||
this.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "edited video note",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[nostr] Edited video rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
publishSummary.accepted.forEach(({ url }) =>
|
||||
console.log(`Edited video published to ${url}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[nostr] Edited video not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
} catch (err) {
|
||||
console.error("Edit failed:", err);
|
||||
@@ -5461,16 +5479,52 @@ class NostrClient {
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error(`Failed to revert on ${url}`, err);
|
||||
}
|
||||
})
|
||||
const publishResults = await publishEventToRelays(
|
||||
this.pool,
|
||||
this.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "video revert",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[nostr] Video revert rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
publishSummary.accepted.forEach(({ url }) =>
|
||||
console.log(`Revert event published to ${url}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[nostr] Video revert not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
|
||||
export const RELAY_PUBLISH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeReason(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "publish failed";
|
||||
}
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error.trim();
|
||||
}
|
||||
if (error === undefined || error === null) {
|
||||
return "publish failed";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch (_) {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export class RelayPublishError extends Error {
|
||||
constructor(message, results = [], options = {}) {
|
||||
const finalMessage =
|
||||
typeof message === "string" && message.trim()
|
||||
? message.trim()
|
||||
: "Failed to publish event to any relay.";
|
||||
super(finalMessage);
|
||||
this.name = "RelayPublishError";
|
||||
|
||||
const contextValue =
|
||||
typeof options.context === "string" && options.context.trim()
|
||||
? options.context.trim()
|
||||
: null;
|
||||
if (contextValue) {
|
||||
this.context = contextValue;
|
||||
}
|
||||
|
||||
this.relayResults = Array.isArray(results)
|
||||
? results.map((result) => ({
|
||||
url: result?.url || "",
|
||||
success: !!result?.success,
|
||||
error: result?.error || null,
|
||||
reason: normalizeReason(result?.error),
|
||||
}))
|
||||
: [];
|
||||
|
||||
this.relayFailures = this.relayResults
|
||||
.filter((entry) => !entry.success)
|
||||
.map((entry) => ({
|
||||
url: entry.url,
|
||||
error: entry.error,
|
||||
reason: entry.reason,
|
||||
}));
|
||||
|
||||
this.acceptedRelays = this.relayResults
|
||||
.filter((entry) => entry.success)
|
||||
.map((entry) => entry.url);
|
||||
}
|
||||
}
|
||||
|
||||
export function publishEventToRelay(pool, url, event, options = {}) {
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === "number" && options.timeoutMs > 0
|
||||
? options.timeoutMs
|
||||
: RELAY_PUBLISH_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finalize = (success, error = null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({ url, success, error });
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
finalize(false, new Error("publish timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const pub = pool?.publish?.([url], event);
|
||||
|
||||
if (pub && typeof pub.on === "function") {
|
||||
const registerHandler = (eventName, handler) => {
|
||||
try {
|
||||
pub.on(eventName, handler);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[nostrPublish] Relay publish handle rejected ${eventName} listener:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
};
|
||||
|
||||
const handleFailure = (reason) => {
|
||||
clearTimeout(timeoutId);
|
||||
const err =
|
||||
reason instanceof Error
|
||||
? reason
|
||||
: new Error(String(reason || "publish failed"));
|
||||
finalize(false, err);
|
||||
};
|
||||
|
||||
let handlerRegistered = false;
|
||||
handlerRegistered =
|
||||
registerHandler("ok", handleSuccess) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("seen", handleSuccess) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("failed", handleFailure) || handlerRegistered;
|
||||
|
||||
if (handlerRegistered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pub && typeof pub.then === "function") {
|
||||
pub
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function publishEventToRelays(pool, urls, event, options = {}) {
|
||||
const list = Array.isArray(urls) ? urls : [];
|
||||
return Promise.all(
|
||||
list.map((url) => publishEventToRelay(pool, url, event, options)),
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizePublishResults(results = []) {
|
||||
const accepted = [];
|
||||
const failed = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result?.success) {
|
||||
accepted.push(result);
|
||||
} else {
|
||||
failed.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted, failed };
|
||||
}
|
||||
|
||||
export function assertAnyRelayAccepted(results = [], options = {}) {
|
||||
const summary = summarizePublishResults(results);
|
||||
if (summary.accepted.length > 0) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const contextValue =
|
||||
typeof options.context === "string" && options.context.trim()
|
||||
? options.context.trim()
|
||||
: "";
|
||||
const messageOverride =
|
||||
typeof options.message === "string" && options.message.trim()
|
||||
? options.message.trim()
|
||||
: "";
|
||||
|
||||
const message =
|
||||
messageOverride ||
|
||||
(contextValue
|
||||
? `Failed to publish ${contextValue} to any relay.`
|
||||
: "Failed to publish event to any relay.");
|
||||
|
||||
throw new RelayPublishError(message, results, { context: contextValue });
|
||||
}
|
||||
+49
-17
@@ -1,6 +1,10 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js";
|
||||
import { buildRelayListEvent } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
const MODE_SEQUENCE = ["both", "read", "write"];
|
||||
|
||||
@@ -497,28 +501,56 @@ class RelayPreferencesManager {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const publishResults = await Promise.allSettled(
|
||||
targets.map((url) => nostrClient.pool.publish([url], signedEvent))
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
targets,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
const accepted = [];
|
||||
const failed = [];
|
||||
|
||||
publishResults.forEach((result, index) => {
|
||||
const url = targets[index];
|
||||
if (result.status === "fulfilled") {
|
||||
accepted.push(url);
|
||||
} else {
|
||||
failed.push({ url, reason: result.reason || null });
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "relay preferences update",
|
||||
message: "No relays accepted the update.",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const ok = accepted.length > 0;
|
||||
if (ok) {
|
||||
this.lastEvent = signedEvent;
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
return { ok, event: signedEvent, accepted, failed, targets };
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const accepted = publishSummary.accepted.map(({ url }) => url);
|
||||
const failed = publishSummary.failed.map(({ url, error: relayError }) => ({
|
||||
url,
|
||||
reason: relayError || null,
|
||||
}));
|
||||
|
||||
this.lastEvent = signedEvent;
|
||||
|
||||
return { ok: true, event: signedEvent, accepted, failed, targets };
|
||||
}
|
||||
|
||||
getLastLoadSource() {
|
||||
|
||||
+101
-16
@@ -3,13 +3,61 @@ import {
|
||||
nostrClient,
|
||||
convertEventToVideo as sharedConvertEventToVideo,
|
||||
} from "./nostr.js";
|
||||
import { attachHealthBadges } from "./gridHealth.js";
|
||||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||
import {
|
||||
buildSubscriptionListEvent,
|
||||
SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
} from "./nostrEventSchemas.js";
|
||||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
let attachHealthBadgesLoader = null;
|
||||
let attachHealthBadgesErrorLogged = false;
|
||||
|
||||
function scheduleAttachHealthBadges(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attachHealthBadgesLoader) {
|
||||
attachHealthBadgesLoader = import("./gridHealth.js")
|
||||
.then((module) => module?.attachHealthBadges || null)
|
||||
.catch((error) => {
|
||||
if (!attachHealthBadgesErrorLogged) {
|
||||
attachHealthBadgesErrorLogged = true;
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[SubscriptionsManager] Failed to load grid health helpers:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
attachHealthBadgesLoader
|
||||
.then((fn) => {
|
||||
if (typeof fn === "function") {
|
||||
try {
|
||||
fn(container);
|
||||
} catch (error) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[SubscriptionsManager] attachHealthBadges threw:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Errors are already logged above; no-op here to keep Promise chains quiet.
|
||||
});
|
||||
}
|
||||
|
||||
function getAbsoluteShareUrl(nevent) {
|
||||
if (!nevent) {
|
||||
@@ -179,25 +227,62 @@ class SubscriptionsManager {
|
||||
content: cipherText,
|
||||
});
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(evt);
|
||||
await Promise.all(
|
||||
nostrClient.relays.map(async (relay) => {
|
||||
try {
|
||||
await nostrClient.pool.publish([relay], signedEvent);
|
||||
} catch (e) {
|
||||
signedEvent = await window.nostr.signEvent(evt);
|
||||
} catch (signErr) {
|
||||
console.error("Failed to sign subscription list:", signErr);
|
||||
throw signErr;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "subscription list",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[SubscriptionsManager] Failed to publish to ${relay}`,
|
||||
e
|
||||
`[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.subsEventId = signedEvent.id;
|
||||
console.log("Subscription list published, event id:", signedEvent.id);
|
||||
} catch (signErr) {
|
||||
console.error("Failed to sign/publish subscription list:", signErr);
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.subsEventId = signedEvent.id;
|
||||
const acceptedUrls = publishSummary.accepted.map(({ url }) => url);
|
||||
console.log(
|
||||
"Subscription list published, event id:",
|
||||
signedEvent.id,
|
||||
"accepted relays:",
|
||||
acceptedUrls
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,7 +694,7 @@ class SubscriptionsManager {
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
attachHealthBadges(container);
|
||||
scheduleAttachHealthBadges(container);
|
||||
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
|
||||
if (!window.app?.handleUrlHealthBadge) {
|
||||
return;
|
||||
|
||||
+42
-8
@@ -1,6 +1,10 @@
|
||||
// js/userBlocks.js
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
function normalizeHex(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
@@ -257,16 +261,46 @@ class UserBlockListManager {
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
await Promise.all(
|
||||
nostrClient.relays.map(async (relay) => {
|
||||
try {
|
||||
await nostrClient.pool.publish([relay], signedEvent);
|
||||
} catch (err) {
|
||||
console.error(`[UserBlockList] Failed to publish to ${relay}:`, err);
|
||||
}
|
||||
})
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "block list",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[UserBlockList] Block list rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[UserBlockList] Block list not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.blockEventId = signedEvent.id;
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
const originalWindowNostr = window.nostr;
|
||||
|
||||
window.nostr = {
|
||||
...(originalWindowNostr || {}),
|
||||
signEvent: async (event) => ({
|
||||
...event,
|
||||
id: event?.id || `stub-${Math.random().toString(36).slice(2)}`,
|
||||
sig: "stub-signature",
|
||||
}),
|
||||
nip04: {
|
||||
...(originalWindowNostr?.nip04 || {}),
|
||||
encrypt: async () => "cipher-text",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const { RelayPublishError } = await import("../js/nostrPublish.js");
|
||||
const { nostrClient } = await import("../js/nostr.js");
|
||||
const { subscriptions } = await import("../js/subscriptions.js");
|
||||
const { userBlocks } = await import("../js/userBlocks.js");
|
||||
|
||||
function createRejectingPool(reason = "relay rejected") {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
publish(urls, event) {
|
||||
calls.push({
|
||||
urls: Array.isArray(urls) ? [...urls] : [],
|
||||
event,
|
||||
});
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "failed") {
|
||||
handler(new Error(reason));
|
||||
}
|
||||
return this;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotRelayState(client) {
|
||||
return {
|
||||
relays: Array.isArray(client.relays) ? [...client.relays] : [],
|
||||
readRelays: Array.isArray(client.readRelays)
|
||||
? [...client.readRelays]
|
||||
: [],
|
||||
writeRelays: Array.isArray(client.writeRelays)
|
||||
? [...client.writeRelays]
|
||||
: [],
|
||||
pool: client.pool,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreRelayState(client, snapshot) {
|
||||
client.relays = [...snapshot.relays];
|
||||
client.readRelays = [...snapshot.readRelays];
|
||||
client.writeRelays = [...snapshot.writeRelays];
|
||||
client.pool = snapshot.pool;
|
||||
}
|
||||
|
||||
function applyTestRelayState(client, relays, pool) {
|
||||
client.relays = [...relays];
|
||||
client.readRelays = [...relays];
|
||||
client.writeRelays = [...relays];
|
||||
client.pool = pool;
|
||||
}
|
||||
|
||||
async function testPublishVideoRejectsWhenAllRelaysFail() {
|
||||
const testRelays = [
|
||||
"wss://relay-a.example",
|
||||
"wss://relay-b.example",
|
||||
];
|
||||
const pool = createRejectingPool("video rejected");
|
||||
const relaySnapshot = snapshotRelayState(nostrClient);
|
||||
applyTestRelayState(nostrClient, testRelays, pool);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
nostrClient.publishVideo(
|
||||
{
|
||||
title: "Test video",
|
||||
magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890",
|
||||
mode: "live",
|
||||
},
|
||||
"f".repeat(64)
|
||||
),
|
||||
(error) => {
|
||||
assert.ok(
|
||||
error instanceof RelayPublishError,
|
||||
"publishVideo should reject with RelayPublishError"
|
||||
);
|
||||
assert.equal(
|
||||
error.relayFailures.length,
|
||||
testRelays.length,
|
||||
"relayFailures should include every relay"
|
||||
);
|
||||
const urls = error.relayFailures
|
||||
.map((entry) => entry.url)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
urls,
|
||||
[...testRelays].sort(),
|
||||
"relayFailures should include the rejecting relay URLs"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pool.calls.length,
|
||||
testRelays.length,
|
||||
"publishVideo should attempt each relay"
|
||||
);
|
||||
} finally {
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPublishSubscriptionListRejectsWhenAllRelaysFail() {
|
||||
const testRelays = [
|
||||
"wss://relay-a.example",
|
||||
"wss://relay-b.example",
|
||||
];
|
||||
const pool = createRejectingPool("subscription rejected");
|
||||
const relaySnapshot = snapshotRelayState(nostrClient);
|
||||
applyTestRelayState(nostrClient, testRelays, pool);
|
||||
|
||||
const originalSubs = new Set(subscriptions.subscribedPubkeys);
|
||||
const originalSubsEventId = subscriptions.subsEventId;
|
||||
subscriptions.subscribedPubkeys = new Set(["abcdef1234567890"]);
|
||||
subscriptions.subsEventId = null;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
subscriptions.publishSubscriptionList("f".repeat(64)),
|
||||
(error) => {
|
||||
assert.ok(
|
||||
error instanceof RelayPublishError,
|
||||
"publishSubscriptionList should reject with RelayPublishError"
|
||||
);
|
||||
assert.equal(
|
||||
error.relayFailures.length,
|
||||
testRelays.length,
|
||||
"relayFailures should include each relay for subscriptions"
|
||||
);
|
||||
const urls = error.relayFailures
|
||||
.map((entry) => entry.url)
|
||||
.sort();
|
||||
assert.deepEqual(urls, [...testRelays].sort());
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pool.calls.length,
|
||||
testRelays.length,
|
||||
"publishSubscriptionList should attempt each relay"
|
||||
);
|
||||
assert.equal(
|
||||
subscriptions.subsEventId,
|
||||
null,
|
||||
"subsEventId should remain unset on failure"
|
||||
);
|
||||
} finally {
|
||||
subscriptions.subscribedPubkeys = originalSubs;
|
||||
subscriptions.subsEventId = originalSubsEventId;
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPublishBlockListRejectsWhenAllRelaysFail() {
|
||||
const testRelays = [
|
||||
"wss://relay-a.example",
|
||||
"wss://relay-b.example",
|
||||
];
|
||||
const pool = createRejectingPool("block list rejected");
|
||||
const relaySnapshot = snapshotRelayState(nostrClient);
|
||||
applyTestRelayState(nostrClient, testRelays, pool);
|
||||
|
||||
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]);
|
||||
userBlocks.blockEventId = null;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
userBlocks.publishBlockList("f".repeat(64)),
|
||||
(error) => {
|
||||
assert.ok(
|
||||
error instanceof RelayPublishError,
|
||||
"publishBlockList should reject with RelayPublishError"
|
||||
);
|
||||
assert.equal(
|
||||
error.relayFailures.length,
|
||||
testRelays.length,
|
||||
"relayFailures should include each relay for block list"
|
||||
);
|
||||
const urls = error.relayFailures
|
||||
.map((entry) => entry.url)
|
||||
.sort();
|
||||
assert.deepEqual(urls, [...testRelays].sort());
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pool.calls.length,
|
||||
testRelays.length,
|
||||
"publishBlockList should attempt each relay"
|
||||
);
|
||||
assert.equal(
|
||||
userBlocks.blockEventId,
|
||||
null,
|
||||
"blockEventId should remain unset on failure"
|
||||
);
|
||||
} finally {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
await testPublishVideoRejectsWhenAllRelaysFail();
|
||||
await testPublishSubscriptionListRejectsWhenAllRelaysFail();
|
||||
await testPublishBlockListRejectsWhenAllRelaysFail();
|
||||
|
||||
console.log("nostr publish rejection tests passed");
|
||||
} finally {
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete window.nostr;
|
||||
} else {
|
||||
window.nostr = originalWindowNostr;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user