mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.
- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
{ unconfirmed: true } on all-timeout instead of throwing; explicit relay
rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
to the shared helper), user mute/block list (10000), relay list (10002), DM
relay hints (10050), hashtag preferences (30015).
Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.
Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
403 lines
12 KiB
JavaScript
403 lines
12 KiB
JavaScript
import { isDevMode } from "./config.js";
|
|
import { devLogger } from "./utils/logger.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, optimistic = false) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
resolve({ url, success, error, optimistic });
|
|
};
|
|
|
|
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) {
|
|
devLogger.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("failed", handleFailure) || handlerRegistered;
|
|
|
|
// [email protected] removed the optional "seen" callback from relay
|
|
// publish handles. Attempting to register it now produces an
|
|
// asynchronous TypeError that bubbles up as an unhandled rejection and
|
|
// prevents watch-history snapshots from ever resolving. We therefore
|
|
// skip registering a "seen" listener and rely on "ok" (per NIP-20) for
|
|
// success notifications. Legacy relays that only emit "seen" fall back
|
|
// to the optimistic success path below because no handlers end up
|
|
// registered.
|
|
|
|
if (handlerRegistered) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// nostr-tools SimplePool.publish(relays, event) returns an ARRAY of
|
|
// per-relay promises (one per url). Each MUST be consumed or a relay that
|
|
// rejects ("blocked", "auth-required", "publish timed out", websocket
|
|
// error, …) surfaces as an uncaught promise rejection. With a large/flaky
|
|
// relay list that floods the event loop and can freeze the tab. We pass a
|
|
// single [url], so the array has one promise; allSettled never rejects, so
|
|
// every rejection is consumed here.
|
|
if (Array.isArray(pub)) {
|
|
Promise.allSettled(pub).then((settled) => {
|
|
clearTimeout(timeoutId);
|
|
const anyOk = settled.some((entry) => entry.status === "fulfilled");
|
|
if (anyOk) {
|
|
finalize(true);
|
|
return;
|
|
}
|
|
const firstRejection = settled.find(
|
|
(entry) => entry.status === "rejected",
|
|
);
|
|
finalize(
|
|
false,
|
|
firstRejection?.reason instanceof Error
|
|
? firstRejection.reason
|
|
: new Error(String(firstRejection?.reason || "publish failed")),
|
|
);
|
|
});
|
|
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;
|
|
}
|
|
|
|
// No confirmation channel: the relay handle exposed neither an ok/failed
|
|
// event nor a thenable. We still optimistically report success for legacy
|
|
// "seen"-only relays, but mark the result `optimistic` and log it so a
|
|
// silently-unconfirmed publish is traceable rather than indistinguishable
|
|
// from a real relay ack (publish audit #3).
|
|
clearTimeout(timeoutId);
|
|
devLogger.warn(
|
|
`[nostrPublish] Optimistic (unconfirmed) publish success for ${url}: relay gave no ok/failed/then signal.`,
|
|
);
|
|
finalize(true, null, true);
|
|
});
|
|
}
|
|
|
|
export function publishEventToRelays(pool, urls, event, options = {}) {
|
|
const list = Array.isArray(urls) ? urls : [];
|
|
|
|
if (list.length === 0) {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
const promises = list.map((url) =>
|
|
publishEventToRelay(pool, url, event, options),
|
|
);
|
|
|
|
if (options.waitForAll === true) {
|
|
return Promise.all(promises);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let resolved = false;
|
|
let pending = promises.length;
|
|
const results = [];
|
|
|
|
promises.forEach((p) => {
|
|
p.then(
|
|
(result) => {
|
|
// If we already resolved, we don't need to track further results
|
|
// for the return value, but the promises will still settle in background.
|
|
if (resolved) {
|
|
return;
|
|
}
|
|
|
|
results.push(result);
|
|
pending--;
|
|
|
|
if (result.success) {
|
|
resolved = true;
|
|
resolve(results);
|
|
} else if (pending === 0) {
|
|
resolved = true;
|
|
resolve(results);
|
|
}
|
|
},
|
|
(error) => {
|
|
if (resolved) {
|
|
return;
|
|
}
|
|
// Treat rejection as a failure result so we don't hang
|
|
results.push({ success: false, error });
|
|
pending--;
|
|
|
|
if (pending === 0) {
|
|
resolved = true;
|
|
resolve(results);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
// Non-enumerable key so the tally rides along with a signed event without ever
|
|
// polluting JSON.stringify / relay payloads.
|
|
export const RELAY_PUBLISH_SUMMARY_KEY = "__relayPublishSummary";
|
|
|
|
/**
|
|
* Reduce a {accepted, failed} publish summary to a compact {accepted, total}
|
|
* tally and stash it on the event (non-enumerable). Best-effort: a frozen or
|
|
* non-object event is left untouched.
|
|
*/
|
|
export function attachRelayPublishSummary(event, summary) {
|
|
if (!event || typeof event !== "object") {
|
|
return event;
|
|
}
|
|
const accepted = Array.isArray(summary?.accepted) ? summary.accepted.length : 0;
|
|
const failed = Array.isArray(summary?.failed) ? summary.failed.length : 0;
|
|
try {
|
|
Object.defineProperty(event, RELAY_PUBLISH_SUMMARY_KEY, {
|
|
value: { accepted, total: accepted + failed },
|
|
enumerable: false,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
} catch (_) {
|
|
// ignore — the tally is purely informational.
|
|
}
|
|
return event;
|
|
}
|
|
|
|
/** Read back the compact tally attached by attachRelayPublishSummary, if any. */
|
|
export function readRelayPublishSummary(event) {
|
|
if (!event || typeof event !== "object") {
|
|
return null;
|
|
}
|
|
const tally = event[RELAY_PUBLISH_SUMMARY_KEY];
|
|
return tally && typeof tally === "object" ? tally : null;
|
|
}
|
|
|
|
/**
|
|
* Turn a relay-publish tally into a user-facing message + tone, so the UI can
|
|
* tell the user how many relays actually accepted their video instead of an
|
|
* opaque "shared successfully" that hides partial/total failure.
|
|
*
|
|
* @param {{ accepted?: number, total?: number }} tally
|
|
* @returns {{ tone: "success"|"warning"|"error", message: string }}
|
|
*/
|
|
export function describePublishOutcome({ accepted = 0, total = 0 } = {}) {
|
|
const a = Number.isFinite(accepted) ? Math.max(0, Math.floor(accepted)) : 0;
|
|
const t = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0;
|
|
|
|
// No relay count available — fall back to the generic confirmation.
|
|
if (t <= 0) {
|
|
return { tone: "success", message: "Video shared successfully!" };
|
|
}
|
|
if (a <= 0) {
|
|
return {
|
|
tone: "error",
|
|
message:
|
|
"Video could not be published to any relay. Check your relay list and try again.",
|
|
};
|
|
}
|
|
if (a < t) {
|
|
return {
|
|
tone: "warning",
|
|
message: `Video published to ${a} of ${t} relays — some relays rejected it.`,
|
|
};
|
|
}
|
|
return {
|
|
tone: "success",
|
|
message: `Video published to ${a} ${a === 1 ? "relay" : "relays"}!`,
|
|
};
|
|
}
|
|
|
|
// A RelayPublishError whose per-relay failures are ALL "publish timeout" means the
|
|
// event was sent but not ACKed in the window — NOT that any relay rejected it. For
|
|
// an idempotent, replaceable event this almost always persisted (users see it
|
|
// after a refresh), so callers that publish such events can treat this as a soft
|
|
// success instead of a hard error. An explicit rejection has a different reason
|
|
// and returns false, so it still fails loudly.
|
|
export function relayPublishFailuresAreAllTimeouts(error) {
|
|
const failures = Array.isArray(error?.relayFailures) ? error.relayFailures : [];
|
|
if (!failures.length) {
|
|
return false;
|
|
}
|
|
return failures.every((failure) => {
|
|
const reason = typeof failure?.reason === "string" ? failure.reason : "";
|
|
return /timeout/i.test(reason);
|
|
});
|
|
}
|
|
|
|
// Like assertAnyRelayAccepted, but for idempotent/replaceable publishes: when the
|
|
// only reason nothing was accepted is that every relay timed out (unconfirmed),
|
|
// don't throw — return a summary flagged `{ unconfirmed: true }` so the caller can
|
|
// treat it as an optimistic success (reconciles on next load). An explicit relay
|
|
// rejection (or an empty/other failure set) still throws exactly as before.
|
|
export function assertAnyRelayAcceptedOrUnconfirmed(results = [], options = {}) {
|
|
try {
|
|
const summary = assertAnyRelayAccepted(results, options);
|
|
return { ...summary, unconfirmed: false };
|
|
} catch (error) {
|
|
if (error instanceof RelayPublishError && relayPublishFailuresAreAllTimeouts(error)) {
|
|
return {
|
|
accepted: [],
|
|
failed: (error.relayFailures || []).map((f) => ({
|
|
url: f.url,
|
|
error: f.error || null,
|
|
reason: f.reason,
|
|
})),
|
|
unconfirmed: true,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|