mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +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,
|
||||
|
||||
@@ -531,16 +531,31 @@ export class RelayClient {
|
||||
this.relayUrl = await getRelayWsUrl();
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[relay] connecting live WS to ${this.relayUrl} (serverless=${this.serverless})`,
|
||||
);
|
||||
|
||||
const generation = ++this.connectionGeneration;
|
||||
this.onMessageChannel = new Channel<unknown>((message) => {
|
||||
void this.handleWsMessage(message, generation);
|
||||
});
|
||||
|
||||
this.wsId = await invoke<number>("plugin:websocket|connect", {
|
||||
url: this.relayUrl,
|
||||
onMessage: this.onMessageChannel,
|
||||
config: {},
|
||||
});
|
||||
try {
|
||||
this.wsId = await invoke<number>("plugin:websocket|connect", {
|
||||
url: this.relayUrl,
|
||||
onMessage: this.onMessageChannel,
|
||||
config: {},
|
||||
});
|
||||
console.info(
|
||||
`[relay] live WS connected to ${this.relayUrl} (id=${this.wsId})`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[relay] live WS connect FAILED to ${this.relayUrl}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.serverless) {
|
||||
// Generic public relays don't require (or send) a NIP-42 AUTH challenge.
|
||||
@@ -666,9 +681,15 @@ export class RelayClient {
|
||||
|
||||
private async sendRaw(payload: unknown[]) {
|
||||
if (this.wsId === null) {
|
||||
console.error(
|
||||
`[relay] sendRaw FAILED — socket not connected; payload=${payload[0]}`,
|
||||
);
|
||||
throw new Error("Relay socket is not connected.");
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`[relay] → ${payload[0]} ${JSON.stringify(payload).slice(0, 160)}`,
|
||||
);
|
||||
await invoke("plugin:websocket|send", {
|
||||
id: this.wsId,
|
||||
message: {
|
||||
@@ -728,9 +749,15 @@ export class RelayClient {
|
||||
timeoutMessage: string,
|
||||
sendErrorMessage: string,
|
||||
) {
|
||||
console.info(
|
||||
`[relay] publishEvent kind=${event.kind} id=${event.id.slice(0, 8)} → ${this.relayUrl}`,
|
||||
);
|
||||
return new Promise<RelayEvent>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.pendingEvents.delete(event.id);
|
||||
console.warn(
|
||||
`[relay] publishEvent TIMEOUT id=${event.id.slice(0, 8)} (no OK in 8s) — ${timeoutMessage}`,
|
||||
);
|
||||
reject(new Error(timeoutMessage));
|
||||
}, 8_000);
|
||||
|
||||
@@ -868,8 +895,14 @@ export class RelayClient {
|
||||
private handleEvent(subId: string, event: RelayEvent) {
|
||||
const subscription = this.subscriptions.get(subId);
|
||||
if (!subscription) {
|
||||
console.debug(
|
||||
`[relay] ← EVENT for UNKNOWN sub ${subId} (kind ${event.kind})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.debug(
|
||||
`[relay] ← EVENT sub=${subId.slice(0, 12)} kind=${event.kind} (${subscription.mode})`,
|
||||
);
|
||||
|
||||
if (subscription.mode === "history") {
|
||||
subscription.events.push(event);
|
||||
@@ -921,6 +954,9 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
private handleOk(eventId: string, success: boolean, message: string) {
|
||||
console.info(
|
||||
`[relay] OK id=${eventId.slice(0, 8)} accepted=${success} msg=${JSON.stringify(message)}`,
|
||||
);
|
||||
if (this.authRequest && this.authRequest.pendingEventId === eventId) {
|
||||
window.clearTimeout(this.authRequest.timeout);
|
||||
const authRequest = this.authRequest;
|
||||
|
||||
@@ -719,6 +719,45 @@ export async function getEventById(eventId: string): Promise<RelayEvent> {
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch channel message history over the multi-relay pool (serverless mode).
|
||||
* Reads from the same relay set used for writes so reads/writes converge
|
||||
* (the live-WS path is single-relay and split-brains in serverless mode).
|
||||
*/
|
||||
export async function queryChannelMessages(
|
||||
channelId: string,
|
||||
kinds: number[],
|
||||
limit: number,
|
||||
until?: number,
|
||||
): Promise<RelayEvent[]> {
|
||||
return invokeTauri<RelayEvent[]>("query_channel_messages", {
|
||||
channelId,
|
||||
kinds,
|
||||
limit,
|
||||
until: until ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a persistent live subscription for a channel across ALL relays
|
||||
* (serverless). New events are emitted as `serverless-event:<channelId>` Tauri
|
||||
* events. Returns the subscription id for teardown. Standard Nostr realtime —
|
||||
* subscribe to every relay at once and merge.
|
||||
*/
|
||||
export async function subscribeChannelMessages(
|
||||
channelId: string,
|
||||
kinds: number[],
|
||||
): Promise<string> {
|
||||
return invokeTauri<string>("subscribe_channel_messages", {
|
||||
channelId,
|
||||
kinds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsubscribeChannelMessages(subId: string): Promise<void> {
|
||||
await invokeTauri("unsubscribe_channel_messages", { subId });
|
||||
}
|
||||
|
||||
export async function sendChannelMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
|
||||
Reference in New Issue
Block a user