mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
fix(serverless): standard multi-relay Nostr — fix split-brain reads, query flood, single-relay live subscription
The serverless transport was behaving like it talked to the bespoke Sprout
server instead of a standard multi-relay Nostr client. Three real bugs:
1. Split-brain: messages were sent over the multi-relay pool (landing on
whichever relay accepts, e.g. nos.lol when damus rate-limits) but READ over
a single-relay live WS (damus only) — so your own message was invisible.
Fix: in serverless, send + history + LIVE SUBSCRIPTION all go through the
Rust multi-relay pool. New persistent pool subscribe()/unsubscribe() keeps a
REQ open on ALL relays at once and merges+dedups (standard Nostr, like damus
SimplePool). New commands: query_channel_messages, subscribe/unsubscribe_
channel_messages; events streamed to the UI via serverless-event:<channel>.
2. Query flood: get_channels ran {kinds:[39000],limit:5000} (discover ALL
network channels) + a 500-id member-count batch on a public relay. Fix:
serverless skips network-wide discovery; only your member channels are
listed (4 scoped queries).
3. Diagnostics: added [serverless]/[pool] backend logging and [relay] webview
console logging so failures are visible, not silent.
Proven with live tests against damus+nos.lol: multi-relay publish survives
per-relay rate-limit, and the live subscription delivers a message published
to a different relay than the naive primary (the split-brain fix).
This commit is contained in:
@@ -13,12 +13,16 @@ import {
|
||||
resolveReplyRootId,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
addReaction,
|
||||
deleteMessage,
|
||||
editMessage,
|
||||
queryChannelMessages,
|
||||
removeReaction,
|
||||
sendChannelMessage,
|
||||
subscribeChannelMessages,
|
||||
unsubscribeChannelMessages,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
@@ -40,10 +44,31 @@ function isEncryptedChannel(channel: Channel | null): boolean {
|
||||
// from the on-render overlay.
|
||||
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
|
||||
import {
|
||||
CHANNEL_EVENT_KINDS,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
} from "@/shared/constants/kinds";
|
||||
|
||||
/**
|
||||
* Fetch channel history. In serverless mode, non-encrypted channels go through
|
||||
* the Rust multi-relay pool (`queryChannelMessages`) so reads hit the same
|
||||
* relay set as writes — the live-WS path is single-relay and split-brains.
|
||||
* Encrypted channels and server mode keep the existing live-WS path.
|
||||
*/
|
||||
async function fetchHistoryForChannel(
|
||||
channel: Channel,
|
||||
limit: number,
|
||||
): Promise<RelayEvent[]> {
|
||||
if (isActiveWorkspaceServerless() && !isEncryptedChannel(channel)) {
|
||||
return queryChannelMessages(channel.id, [...CHANNEL_EVENT_KINDS], limit);
|
||||
}
|
||||
return relayClient.fetchChannelHistory(
|
||||
channel.id,
|
||||
limit,
|
||||
isEncryptedChannel(channel),
|
||||
);
|
||||
}
|
||||
|
||||
type MessageQueryContext = {
|
||||
optimisticId: string;
|
||||
previousMessages: RelayEvent[];
|
||||
@@ -146,10 +171,9 @@ export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(
|
||||
channel.id,
|
||||
const history = await fetchHistoryForChannel(
|
||||
channel,
|
||||
CHANNEL_HISTORY_LIMIT,
|
||||
isEncryptedChannel(channel),
|
||||
);
|
||||
const currentMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
@@ -171,18 +195,17 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
const channelType = channel?.channelType ?? null;
|
||||
const encrypted = isEncryptedChannel(channel);
|
||||
const syncLatestHistory = useEffectEvent(async () => {
|
||||
if (!channelId) {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(
|
||||
channelId,
|
||||
const history = await fetchHistoryForChannel(
|
||||
channel,
|
||||
CHANNEL_HISTORY_LIMIT,
|
||||
encrypted,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channelId),
|
||||
channelMessagesKey(channel.id),
|
||||
(current = []) => {
|
||||
const mergedHistory = normalizeTimelineMessages([
|
||||
...current,
|
||||
@@ -233,6 +256,47 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
// Serverless (non-encrypted): subscribe across ALL relays via the Rust
|
||||
// pool, listening for `serverless-event:<channelId>` Tauri events. This is
|
||||
// standard Nostr realtime without the single-relay split-brain of the live
|
||||
// WS. Encrypted channels keep the live-WS gift-wrap path.
|
||||
if (isActiveWorkspaceServerless() && !encrypted) {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let subId: string | undefined;
|
||||
void (async () => {
|
||||
unlisten = await listen<RelayEvent>(
|
||||
`serverless-event:${channelId}`,
|
||||
(event) => {
|
||||
if (!isDisposed) {
|
||||
appendMessage(event.payload);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (isDisposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
subId = await subscribeChannelMessages(channelId, [
|
||||
...CHANNEL_EVENT_KINDS,
|
||||
]);
|
||||
// Initial backfill so existing messages show immediately.
|
||||
void syncLatestHistory().catch(() => {});
|
||||
})().catch((error) => {
|
||||
console.error(
|
||||
"Failed serverless channel subscription",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (unlisten) unlisten();
|
||||
if (subId) void unsubscribeChannelMessages(subId);
|
||||
};
|
||||
}
|
||||
|
||||
const disposeReconnectListener = relayClient.subscribeToReconnects(() => {
|
||||
void syncLatestHistory().catch((error) => {
|
||||
if (!isDisposed) {
|
||||
@@ -373,6 +437,36 @@ export function useSendMessageMutation(
|
||||
};
|
||||
}
|
||||
|
||||
// Serverless: route through the Rust command, which publishes over the
|
||||
// multi-relay connection pool (publish-to-all, succeed-if-any-accepts).
|
||||
// The live-WS path (relayClient.sendMessage) uses a single relay with no
|
||||
// pool/fallback, so it trips public-relay rate limits with no recovery.
|
||||
if (isActiveWorkspaceServerless()) {
|
||||
const result = await sendChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
null,
|
||||
undefined,
|
||||
mentionPubkeys,
|
||||
);
|
||||
return {
|
||||
id: result.eventId,
|
||||
pubkey: identity.pubkey,
|
||||
created_at: result.createdAt,
|
||||
kind: KIND_STREAM_MESSAGE,
|
||||
tags: [
|
||||
["h", channel.id],
|
||||
["p", identity.pubkey],
|
||||
...normalizeMentionPubkeys(
|
||||
mentionPubkeys ?? [],
|
||||
identity.pubkey,
|
||||
).map((pk) => ["p", pk]),
|
||||
],
|
||||
content: content.trim(),
|
||||
sig: "",
|
||||
};
|
||||
}
|
||||
|
||||
return relayClient.sendMessage(
|
||||
channel.id,
|
||||
content,
|
||||
|
||||
Reference in New Issue
Block a user