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:
Michael Neale
2026-06-02 15:35:28 +10:00
parent bf6be68d79
commit 51c89d4e64
9 changed files with 488 additions and 31 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,