Files
bitvid/js/nostrPublish.js
thePR0M3TH3ANandClaude Fable 5 1934f9386a publish: all-timeout content publishes are unconfirmed soft successes (#49)
signAndPublishEvent (video publish/edit/repost/mirror), rebroadcastEvent, and
revertVideo now use assertAnyRelayAcceptedOrUnconfirmed: when every relay
times out without rejecting, the event was sent and almost always persisted,
so it no longer surfaces as "Failed to share video". Explicit rejections
still throw.

Messaging stays honest at every consumer:
- relay tally carries `unconfirmed` (now attached on edits too);
  describePublishOutcome maps 0-accepted+unconfirmed to a WARNING ("sent,
  no relay confirmed yet") — never a fake relay count, never a hard error
- edit/revert controllers say "sent, not confirmed yet" when unconfirmed
- deletes stay CONSERVATIVE: "deleted successfully" only when >=1 relay
  ACKed a tombstone or kind-5 (anyRelayAcceptedInSummaries); an unconfirmed
  revert no longer aborts deleteAllVersions midway, so the kind-5 always runs

Tests: tests/relay-ack-unconfirmed-content.test.mjs (6).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:31 -04:00

442 lines
14 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;
// #49: carry the unconfirmed flag (all relays timed out, nothing rejected)
// so the outcome describer can say "sent, unconfirmed" instead of lying in
// either direction ("published to N relays" / "failed").
const tally =
summary?.unconfirmed === true
? { accepted, total: accepted + failed, unconfirmed: true }
: { accepted, total: accepted + failed };
try {
Object.defineProperty(event, RELAY_PUBLISH_SUMMARY_KEY, {
value: tally,
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,
unconfirmed = false,
} = {}) {
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) {
// #49: every relay timed out without rejecting — the event was sent and
// almost always persisted (it shows up after a refresh). Say exactly that:
// neither a hard failure nor a fake "published to N relays".
if (unconfirmed === true) {
return {
tone: "warning",
message:
"Video sent, but no relay has confirmed receiving it yet. It should appear shortly — refresh in a moment to double-check.",
};
}
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"}!`,
};
}
// #49 delete semantics: a delete flow publishes several events (tombstone
// reverts + kind-5 chunks) and the UI must NOT claim "deleted" unless at least
// one relay actually ACKed something — a false "deleted" is worse than a false
// "failed". An empty list (nothing needed publishing) counts as confirmed.
export function anyRelayAcceptedInSummaries(summaries = []) {
const list = Array.isArray(summaries) ? summaries.filter(Boolean) : [];
if (!list.length) {
return true;
}
return list.some((summary) => {
const accepted = summary?.accepted;
if (Array.isArray(accepted)) {
return accepted.length > 0;
}
return Number.isFinite(accepted) && accepted > 0;
});
}
// 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 });
}