feat(federation): add fire-and-forget S2S typing relay

sendTypingRelay() mirrors sendCallRelay() — direct POST to peers,
no outbox, no retry. Uses federatedId for cross-instance channel
identification. Wired into handleDmTypingStart() for typing_start
and broadcastDmMessage() for typing_stop.
This commit is contained in:
Jannis Braun
2026-04-01 12:53:55 +02:00
parent b5fbc9d90e
commit b3011fb3da
3 changed files with 69 additions and 1 deletions
+4
View File
@@ -30,6 +30,7 @@ import {
getGroupDmTargetOrigins,
isFederationRelayEnabled,
computeFederatedId,
sendTypingRelay,
} from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import type { FederationRelayEvent } from '@backspace/shared';
@@ -191,6 +192,9 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs
}
}
// Relay typing stop to remote peers (fire-and-forget)
sendTypingRelay(dmChannelId, 'dm_typing_stop', message.userId);
for (const member of dmMembers) {
// If this member had closed the DM, resurface it first
if (member.closed === 1) {
@@ -5,6 +5,7 @@ import { generateSnowflake } from './snowflake.js';
import crypto from 'node:crypto';
import type { FederationRelayEvent, FederationRelayParticipant, FederationRelayAttachment, DmMessageWithUser, FederationRelayRequest } from '@backspace/shared';
import { getOurOrigin, buildFederationHeaders } from './federationAuth.js';
import { extractDomain } from '../routes/federation.js';
// ─── Settings Cache ──────────────────────────────────────────────────────────
@@ -470,3 +471,63 @@ export async function sendCallRelay(
return { ok: false, error: err instanceof Error ? err.message : 'fetch_failed' };
}
}
/**
* Send typing indicator events directly to remote peers (bypasses outbox).
* Fire-and-forget — typing is ephemeral, lost packets are acceptable.
*/
export async function sendTypingRelay(
dmChannelId: string,
eventType: 'dm_typing_start' | 'dm_typing_stop',
userId: string,
): Promise<void> {
if (!isFederationRelayEnabled()) return;
const db = getDb();
const participants = getDmParticipants(dmChannelId);
const ourOrigin = getOurOrigin();
// Find the typing user's identity
const typingUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!typingUser) return;
// Get the channel's federatedId for cross-instance identification
const channel = db.select({ federatedId: schema.dmChannels.federatedId })
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, dmChannelId))
.get();
if (!channel?.federatedId) return;
// Find unique remote peer origins
const remoteOrigins = new Set<string>();
for (const p of participants) {
const normalized = p.homeInstance.startsWith('http') ? p.homeInstance : `https://${p.homeInstance}`;
if (normalized !== ourOrigin) {
remoteOrigins.add(normalized);
}
}
if (remoteOrigins.size === 0) return;
const event: FederationRelayEvent = {
eventType,
contextType: 'dm',
messageId: `typing:${userId}:${Date.now()}`,
federatedId: channel.federatedId,
participants,
encryptionVersion: 0,
timestamp: Date.now(),
typing: {
homeUserId: typingUser.homeUserId || typingUser.id,
homeInstance: typingUser.homeInstance || extractDomain(ourOrigin),
username: typingUser.username ?? '',
},
};
// Fire-and-forget to each remote peer
for (const peerOrigin of remoteOrigins) {
sendCallRelay(peerOrigin, [event]).catch(err => {
console.warn(`[federation] Typing relay to ${peerOrigin} failed:`, err);
});
}
}
+4 -1
View File
@@ -11,7 +11,7 @@ import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId } from '../utils/federationOutbox.js';
import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay } from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import { generateFederatedCallToken } from '../routes/livekit.js';
import { config } from '../config.js';
@@ -938,6 +938,9 @@ function handleDmTypingStart(event: Record<string, unknown>, userId: string, use
}
}
// Relay typing indicator to remote peers (fire-and-forget)
sendTypingRelay(dmChannelId, 'dm_typing_start', userId);
const timeout = setTimeout(() => {
typingTimeouts.delete(key);
}, 5000);