Files
backspace/packages/server/src/utils/federationWorker.ts
T
Jannis Braun 44b6317c16 fix: federation relay for group DMs — origin normalization, missing federatedId, local-only broadcasts
Three stacked bugs prevented federation relay from working for group DMs:

1. Origin format mismatch: users.home_instance stores bare domains
   ("nova.ddns.net") but federation_peers.origin stores full URLs
   ("https://nova.ddns.net"). getGroupDmTargetOrigins() built target
   lists from bare domains, so queueOutboxEvent() never matched any
   peers — events were never queued into the outbox.

2. Missing federatedId in outbox reconstruction: the outbox worker
   rebuilt relay events from stored payloads but never copied the
   federatedId field. Receiving instances check this field and rejected
   all member_add/remove/ownership_transfer events with
   "missing_membership_payload".

3. Duplicate channels from cross-instance broadcasts: dm_channel_created
   was sent to ALL members including remote replicas. Users connected to
   multiple instances received the event twice (once per instance),
   creating duplicate group DMs in their sidebar. Fixed by only
   broadcasting to members whose home instance matches the current
   server — remote members receive the channel via federation bootstrap
   on their home instance.
2026-03-27 16:53:35 +01:00

907 lines
30 KiB
TypeScript

import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js';
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
import { config } from '../config.js';
import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js';
import { runFederationJanitor } from './storageJanitor.js';
import { buildFederationHeaders, getOurOrigin } from './federationAuth.js';
import { generateSnowflake } from './snowflake.js';
import { getDmMessageWithUser } from '../routes/dm.js';
import { connectionManager } from '../ws/handler.js';
import { generateThumbnail } from './thumbnail.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
// ─── Constants ──────────────────────────────────────────────────────────────
const OUTBOX_INTERVAL_MS = 10_000; // 10 seconds
const FILE_QUEUE_INTERVAL_MS = 30_000; // 30 seconds
const HEALTH_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
const JANITOR_INTERVAL_MS = 3_600_000; // 1 hour
const OUTBOX_BATCH_LIMIT = 50;
const FILE_QUEUE_BATCH_LIMIT = 5;
const OUTBOX_FETCH_TIMEOUT_MS = 30_000;
const FILE_DOWNLOAD_TIMEOUT_MS = 60_000;
const HEALTH_CHECK_TIMEOUT_MS = 10_000;
/** Exponential backoff schedule by attempt number (1-indexed). Values in milliseconds. */
const BACKOFF_SCHEDULE_MS: readonly number[] = [
30_000, // attempt 1: 30s
60_000, // attempt 2: 1min
300_000, // attempt 3: 5min
900_000, // attempt 4: 15min
3_600_000, // attempt 5: 1hr
21_600_000, // attempt 6: 6hr
86_400_000, // attempt 7+: 24hr cap
];
const MAX_FILE_ATTEMPTS = 10;
const PEER_UNREACHABLE_THRESHOLD = 10;
// ─── Worker State ───────────────────────────────────────────────────────────
let outboxTimer: ReturnType<typeof setTimeout> | null = null;
let fileQueueTimer: ReturnType<typeof setTimeout> | null = null;
let healthCheckTimer: ReturnType<typeof setTimeout> | null = null;
let janitorTimer: ReturnType<typeof setTimeout> | null = null;
let outboxAbortController: AbortController | null = null;
let fileQueueAbortController: AbortController | null = null;
let healthCheckAbortController: AbortController | null = null;
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Compute the backoff delay for a given attempt number (1-indexed).
* Caps at the last entry in BACKOFF_SCHEDULE_MS.
*/
function getBackoffMs(attempt: number): number {
const index = Math.min(attempt - 1, BACKOFF_SCHEDULE_MS.length - 1);
return BACKOFF_SCHEDULE_MS[Math.max(0, index)] ?? 86_400_000;
}
/**
* Get the effective max upload size from instance settings or config fallback.
*/
function getMaxUploadSize(): number {
try {
const db = getDb();
const row = db
.select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get();
return row?.maxUploadSizeBytes ?? config.maxUploadSize;
} catch {
return config.maxUploadSize;
}
}
// ─── Outbox Delivery Worker ─────────────────────────────────────────────────
function scheduleOutboxTick(): void {
outboxTimer = setTimeout(() => {
processOutboxTick().catch((err) => {
console.error('[federation-worker] Outbox tick error:', err);
}).finally(() => {
scheduleOutboxTick();
});
}, OUTBOX_INTERVAL_MS);
}
async function processOutboxTick(): Promise<void> {
if (!isFederationRelayEnabled()) {
return;
}
const db = getDb();
const now = Date.now();
// Fetch outbox entries ready for delivery, joined with active peers
const entries = db
.select({
outboxId: schema.federationOutbox.id,
peerId: schema.federationOutbox.peerId,
contextId: schema.federationOutbox.contextId,
entityId: schema.federationOutbox.entityId,
contextType: schema.federationOutbox.contextType,
eventType: schema.federationOutbox.eventType,
payload: schema.federationOutbox.payload,
encryptionVersion: schema.federationOutbox.encryptionVersion,
attempts: schema.federationOutbox.attempts,
createdAt: schema.federationOutbox.createdAt,
peerOrigin: schema.federationPeers.origin,
peerHmacSecret: schema.federationPeers.hmacSecret,
peerStatus: schema.federationPeers.status,
})
.from(schema.federationOutbox)
.innerJoin(
schema.federationPeers,
eq(schema.federationOutbox.peerId, schema.federationPeers.id),
)
.where(
and(
lte(schema.federationOutbox.nextRetryAt, now),
eq(schema.federationPeers.status, 'active'),
),
)
.orderBy(asc(schema.federationOutbox.createdAt))
.limit(OUTBOX_BATCH_LIMIT)
.all();
if (entries.length === 0) {
return;
}
// Group by peer
const byPeer = new Map<string, typeof entries>();
for (const entry of entries) {
const group = byPeer.get(entry.peerId);
if (group) {
group.push(entry);
} else {
byPeer.set(entry.peerId, [entry]);
}
}
const ourOrigin = getOurOrigin();
for (const [peerId, peerEntries] of byPeer) {
const firstEntry = peerEntries[0];
if (!firstEntry) continue; // Should never happen given grouping logic above
const peerOrigin = firstEntry.peerOrigin;
const peerHmacSecret = firstEntry.peerHmacSecret;
// Build relay events from outbox entries
const events: FederationRelayEvent[] = peerEntries.map((entry) => {
const parsed = JSON.parse(entry.payload) as Partial<FederationRelayEvent>;
const isDm = entry.contextType === 'dm' || !entry.contextType;
const evt: FederationRelayEvent = {
eventType: entry.eventType as FederationRelayEvent['eventType'],
contextType: (entry.contextType ?? 'dm') as 'dm' | 'friend',
messageId: entry.entityId ?? '',
encryptionVersion: (entry.encryptionVersion ?? 0) as 0,
timestamp: entry.createdAt,
};
if (isDm && entry.contextId) evt.dmChannelId = entry.contextId;
if (parsed.federatedId) evt.federatedId = parsed.federatedId;
if (parsed.participants) evt.participants = parsed.participants;
if (parsed.message) evt.message = parsed.message;
if (parsed.reactions) evt.reactions = parsed.reactions;
if (parsed.reaction) evt.reaction = parsed.reaction;
if (parsed.membership) evt.membership = parsed.membership;
if (parsed.ownership) evt.ownership = parsed.ownership;
if (parsed.group) evt.group = parsed.group;
if (parsed.friendship) evt.friendship = parsed.friendship;
// file_rejected event fields
if (parsed.attachmentId) evt.attachmentId = parsed.attachmentId;
if (parsed.sourceFilename) evt.sourceFilename = parsed.sourceFilename;
if (parsed.rejectionReason) evt.rejectionReason = parsed.rejectionReason;
if (parsed.rejectionLimit != null) evt.rejectionLimit = parsed.rejectionLimit;
if (parsed.affectedUserIds) evt.affectedUserIds = parsed.affectedUserIds;
return evt;
});
const request: FederationRelayRequest = {
version: 1,
sourceInstance: ourOrigin,
events,
};
const bodyString = JSON.stringify(request);
const headers = buildFederationHeaders(bodyString, peerHmacSecret, ourOrigin);
// Create an abort controller for this specific request
outboxAbortController = new AbortController();
try {
const response = await fetch(`${peerOrigin}/api/federation/relay`, {
method: 'POST',
headers,
body: bodyString,
signal: AbortSignal.any([
outboxAbortController.signal,
AbortSignal.timeout(OUTBOX_FETCH_TIMEOUT_MS),
]),
});
if (response.ok) {
const result = await response.json() as FederationRelayResponse;
// Delete accepted entries
if (result.accepted.length > 0) {
// Map accepted messageIds to outbox IDs
const acceptedSet = new Set(result.accepted);
const acceptedOutboxIds = peerEntries
.filter((e) => acceptedSet.has(e.entityId))
.map((e) => e.outboxId);
if (acceptedOutboxIds.length > 0) {
db.delete(schema.federationOutbox)
.where(inArray(schema.federationOutbox.id, acceptedOutboxIds))
.run();
}
}
// Log rejected entries (they remain in outbox for retry)
for (const rejection of result.rejected) {
console.warn(
`[federation-worker] Peer ${peerOrigin} rejected message ${rejection.messageId}: ${rejection.reason}`,
);
}
// Store the peer's max upload size for informational display
if (typeof result.maxUploadSize === 'number') {
db.update(schema.federationPeers)
.set({ remoteMaxUploadSize: result.maxUploadSize })
.where(eq(schema.federationPeers.origin, peerOrigin))
.run();
}
// Update peer health
db.update(schema.federationPeers)
.set({
lastSeenAt: now,
consecutiveFailures: 0,
})
.where(eq(schema.federationPeers.id, peerId))
.run();
} else {
console.warn(
`[federation-worker] Peer ${peerOrigin} returned HTTP ${response.status}`,
);
handleOutboxDeliveryFailure(db, peerId, peerEntries, now);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Worker is stopping — don't update anything
return;
}
console.error(
`[federation-worker] Failed to deliver to peer ${peerOrigin}:`,
err instanceof Error ? err.message : err,
);
handleOutboxDeliveryFailure(db, peerId, peerEntries, now);
}
}
}
function handleOutboxDeliveryFailure(
db: ReturnType<typeof getDb>,
peerId: string,
entries: Array<{ outboxId: string; attempts: number | null }>,
now: number,
): void {
// Increment attempts and compute next retry for each entry
for (const entry of entries) {
const newAttempts = (entry.attempts ?? 0) + 1;
const backoffMs = getBackoffMs(newAttempts);
db.update(schema.federationOutbox)
.set({
attempts: newAttempts,
nextRetryAt: now + backoffMs,
})
.where(eq(schema.federationOutbox.id, entry.outboxId))
.run();
}
// Update peer failure tracking
const peer = db
.select({ consecutiveFailures: schema.federationPeers.consecutiveFailures })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
const newFailures = (peer?.consecutiveFailures ?? 0) + 1;
const updates: Record<string, number | string> = {
lastFailureAt: now,
consecutiveFailures: newFailures,
};
if (newFailures >= PEER_UNREACHABLE_THRESHOLD) {
(updates as Record<string, number | string>)['status'] = 'unreachable';
console.warn(
`[federation-worker] Peer ${peerId} marked unreachable after ${newFailures} consecutive failures`,
);
}
db.update(schema.federationPeers)
.set(updates)
.where(eq(schema.federationPeers.id, peerId))
.run();
}
// ─── File Queue Download Worker ─────────────────────────────────────────────
function scheduleFileQueueTick(): void {
fileQueueTimer = setTimeout(() => {
processFileQueueTick().catch((err) => {
console.error('[federation-worker] File queue tick error:', err);
}).finally(() => {
scheduleFileQueueTick();
});
}, FILE_QUEUE_INTERVAL_MS);
}
async function processFileQueueTick(): Promise<void> {
if (!isFederationRelayEnabled()) {
return;
}
const db = getDb();
const now = Date.now();
const pending = db
.select()
.from(schema.federationFileQueue)
.where(
and(
eq(schema.federationFileQueue.status, 'pending'),
lte(schema.federationFileQueue.nextRetryAt, now),
),
)
.limit(FILE_QUEUE_BATCH_LIMIT)
.all();
if (pending.length === 0) {
return;
}
const maxUploadSize = getMaxUploadSize();
for (const entry of pending) {
await processFileQueueEntry(db, entry, maxUploadSize, now);
}
}
function handleSizeRejection(
db: ReturnType<typeof getDb>,
entry: typeof schema.federationFileQueue.$inferSelect,
maxUploadSize: number,
now: number,
): void {
// Look up the local DM message to find the sender and channel info
const localMsg = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.id, entry.dmMessageId))
.get();
if (!localMsg || !localMsg.sourceInstance || !localMsg.sourceMessageId) return;
// Find the attachment row to get its ID
const att = db.select()
.from(schema.attachments)
.where(
and(
eq(schema.attachments.dmMessageId, entry.dmMessageId),
eq(schema.attachments.sourceUrl, entry.sourceUrl),
),
)
.get();
// Resolve the sender's username from their local replicated user stub
const senderUser = db.select()
.from(schema.users)
.where(eq(schema.users.id, localMsg.userId))
.get();
const sourceUsername = senderUser?.displayName || senderUser?.username || 'unknown';
// Update attachment federation status
if (att) {
db.update(schema.attachments)
.set({
federationStatus: 'remote',
federationMeta: JSON.stringify({
sourceInstance: localMsg.sourceInstance,
sourceUserId: localMsg.userId,
sourceUsername,
}),
})
.where(eq(schema.attachments.id, att.id))
.run();
}
// Find local DM members native to THIS instance
const ourOrigin = getOurOrigin();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, localMsg.dmChannelId))
.all();
const affectedUserIds: string[] = [];
for (const member of dmMembers) {
const user = db.select()
.from(schema.users)
.where(eq(schema.users.id, member.userId))
.get();
if (user && (!user.homeInstance || user.homeInstance === ourOrigin)) {
affectedUserIds.push(user.homeUserId || user.id);
}
}
// Queue a file_rejected reverse relay event to the sender's instance
// Extract the filename from the sourceUrl (e.g., "https://sender/api/uploads/12345.png" → "12345.png")
const sourceFilename = entry.sourceUrl.split('/').pop() ?? entry.sourceUrl;
const event: FederationRelayEvent = {
eventType: 'file_rejected',
messageId: localMsg.sourceMessageId,
encryptionVersion: 0,
timestamp: now,
attachmentId: att?.id ?? entry.sourceUrl,
sourceFilename,
rejectionReason: 'size_limit_exceeded',
rejectionLimit: maxUploadSize,
affectedUserIds,
};
queueOutboxEvent(
localMsg.sourceMessageId,
localMsg.dmChannelId,
'file_rejected',
JSON.stringify(event),
[localMsg.sourceInstance],
);
// Broadcast updated message to local clients so they see the 'remote' badge
const updatedMsg = getDmMessageWithUser(entry.dmMessageId);
if (updatedMsg) {
connectionManager.sendToDmMembers(updatedMsg.dmChannelId, {
type: 'dm_message_updated',
message: updatedMsg,
});
}
}
async function processFileQueueEntry(
db: ReturnType<typeof getDb>,
entry: typeof schema.federationFileQueue.$inferSelect,
maxUploadSize: number,
now: number,
): Promise<void> {
// SSRF protection: validate sourceUrl hostname matches peerOrigin hostname
try {
const sourceHostname = new URL(entry.sourceUrl).hostname;
const peerHostname = new URL(entry.peerOrigin).hostname;
if (sourceHostname !== peerHostname) {
console.warn(
`[federation-worker] SSRF blocked: sourceUrl hostname "${sourceHostname}" does not match peer origin "${peerHostname}" for file queue entry ${entry.id}`,
);
db.update(schema.federationFileQueue)
.set({
status: 'rejected',
rejectionReason: 'ssrf_hostname_mismatch',
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
return;
}
} catch {
db.update(schema.federationFileQueue)
.set({
status: 'rejected',
rejectionReason: 'invalid_url',
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
return;
}
// Check file size against limit
if (entry.size > maxUploadSize) {
db.update(schema.federationFileQueue)
.set({
status: 'rejected',
rejectionReason: 'size_limit_exceeded',
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
handleSizeRejection(db, entry, maxUploadSize, now);
return;
}
// Create abort controller for this download
fileQueueAbortController = new AbortController();
try {
const response = await fetch(entry.sourceUrl, {
signal: AbortSignal.any([
fileQueueAbortController.signal,
AbortSignal.timeout(FILE_DOWNLOAD_TIMEOUT_MS),
]),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status} downloading file from ${entry.sourceUrl}`);
}
// Generate a unique local filename using the same pattern as the upload route
const ext = path.extname(entry.originalName) || '';
const localId = generateSnowflake();
const localFilename = `${localId}${ext}`;
const localPath = path.join(config.uploadDir, localFilename);
// Ensure upload directory exists
fs.mkdirSync(config.uploadDir, { recursive: true });
// Stream the response body to disk
const nodeStream = Readable.fromWeb(response.body as ReadableStream);
const writeStream = fs.createWriteStream(localPath);
try {
await pipeline(nodeStream, writeStream);
} catch (pipeErr) {
// Clean up partial file on failure
try { fs.unlinkSync(localPath); } catch { /* ignore cleanup errors */ }
throw pipeErr;
}
// Verify file was written and get actual size
const stat = fs.statSync(localPath);
// Check actual downloaded size against limit (defense in depth)
if (stat.size > maxUploadSize) {
try { fs.unlinkSync(localPath); } catch { /* ignore */ }
db.update(schema.federationFileQueue)
.set({
status: 'rejected',
rejectionReason: 'size_limit_exceeded',
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
handleSizeRejection(db, entry, maxUploadSize, now);
return;
}
// Generate thumbnail for images (same as local upload flow)
let thumbnailFilename: string | null = null;
try {
thumbnailFilename = await generateThumbnail(localPath, entry.mimetype, config.uploadDir);
} catch {
// Non-fatal — full image will be used instead
}
// Update the existing attachment row (created by processCreateEvent with
// sourceUrl as interim filename) to point to the local file
const updated = db.update(schema.attachments)
.set({
filename: localFilename,
size: stat.size,
thumbnailFilename,
})
.where(
and(
eq(schema.attachments.dmMessageId, entry.dmMessageId),
eq(schema.attachments.sourceUrl, entry.sourceUrl),
),
)
.run();
// Fallback: if no existing row was found (e.g., legacy queue entry from
// before processCreateEvent created rows), insert a new one
if (updated.changes === 0) {
db.insert(schema.attachments)
.values({
id: generateSnowflake(),
dmMessageId: entry.dmMessageId,
uploaderId: null,
filename: localFilename,
originalName: entry.originalName,
mimetype: entry.mimetype,
size: stat.size,
thumbnailFilename,
sourceUrl: entry.sourceUrl,
createdAt: now,
})
.run();
}
// Mark file queue entry as completed
db.update(schema.federationFileQueue)
.set({
status: 'completed',
targetFilename: localFilename,
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
console.log(
`[federation-worker] Downloaded federated file: ${entry.originalName} -> ${localFilename}`,
);
// Notify connected clients that the attachment is now available locally
const updatedMsg = getDmMessageWithUser(entry.dmMessageId);
if (updatedMsg) {
connectionManager.sendToDmMembers(updatedMsg.dmChannelId, {
type: 'dm_message_updated',
message: updatedMsg,
});
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Worker is stopping — leave entry as pending for next tick
return;
}
console.error(
`[federation-worker] Failed to download file ${entry.originalName} from ${entry.peerOrigin}:`,
err instanceof Error ? err.message : err,
);
const newAttempts = (entry.attempts ?? 0) + 1;
if (newAttempts > MAX_FILE_ATTEMPTS) {
db.update(schema.federationFileQueue)
.set({
status: 'failed',
attempts: newAttempts,
rejectionReason: 'max_attempts_exceeded',
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
} else {
const backoffMs = getBackoffMs(newAttempts);
db.update(schema.federationFileQueue)
.set({
attempts: newAttempts,
nextRetryAt: now + backoffMs,
})
.where(eq(schema.federationFileQueue.id, entry.id))
.run();
}
}
}
// ─── Health Check Worker ────────────────────────────────────────────────────
function scheduleHealthCheckTick(): void {
healthCheckTimer = setTimeout(() => {
processHealthCheckTick().catch((err) => {
console.error('[federation-worker] Health check tick error:', err);
}).finally(() => {
scheduleHealthCheckTick();
});
}, HEALTH_CHECK_INTERVAL_MS);
}
async function processHealthCheckTick(): Promise<void> {
const db = getDb();
const unreachablePeers = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.status, 'unreachable'))
.all();
if (unreachablePeers.length === 0) {
return;
}
const now = Date.now();
for (const peer of unreachablePeers) {
healthCheckAbortController = new AbortController();
try {
const response = await fetch(`${peer.origin}/api/instance/info`, {
signal: AbortSignal.any([
healthCheckAbortController.signal,
AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
]),
});
if (response.ok) {
db.update(schema.federationPeers)
.set({
status: 'active',
consecutiveFailures: 0,
lastSeenAt: now,
})
.where(eq(schema.federationPeers.id, peer.id))
.run();
console.log(
`[federation-worker] Peer ${peer.origin} recovered — marked active`,
);
}
// If not ok, leave as unreachable — will check again next cycle
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
// Leave as unreachable — will check again next cycle
}
}
}
// ─── Janitor Worker ──────────────────────────────────────────────────────────
function scheduleJanitorTick(): void {
janitorTimer = setTimeout(() => {
runFederationJanitor();
scheduleJanitorTick();
}, JANITOR_INTERVAL_MS);
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
/**
* Trigger checkpoint sync for peers that have never been synced (lastSyncedAt === 0).
* This catches historical messages that existed before the relay was enabled.
*/
async function runInitialSyncForNewPeers(): Promise<void> {
if (!isFederationRelayEnabled()) return;
const db = getDb();
const unsyncedPeers = db
.select()
.from(schema.federationPeers)
.where(and(
eq(schema.federationPeers.status, 'active'),
eq(schema.federationPeers.lastSyncedAt, 0),
))
.all();
if (unsyncedPeers.length === 0) return;
const ourOrigin = getOurOrigin();
for (const peer of unsyncedPeers) {
try {
console.log(`[federation-worker] Running initial sync with ${peer.origin}...`);
let sinceTimestamp = 0;
let totalEvents = 0;
// Paginate through all events from the peer
while (true) {
const body = JSON.stringify({ sinceTimestamp, limit: 100 });
const headers = buildFederationHeaders(body, peer.hmacSecret, ourOrigin);
const response = await fetch(`${peer.origin}/api/federation/sync`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
console.error(`[federation-worker] Sync with ${peer.origin} failed: ${response.status}`);
break;
}
const data = await response.json() as { events: FederationRelayEvent[]; hasMore: boolean; checkpoint: number };
if (data.events.length === 0) break;
// Relay the events through our own relay endpoint logic
// For simplicity, POST them to ourselves
const relayBody = JSON.stringify({
version: 1,
sourceInstance: peer.origin,
events: data.events,
});
const relayHeaders = buildFederationHeaders(relayBody, peer.hmacSecret, peer.origin);
await fetch(`${ourOrigin}/api/federation/relay`, {
method: 'POST',
headers: relayHeaders,
body: relayBody,
signal: AbortSignal.timeout(30_000),
});
totalEvents += data.events.length;
sinceTimestamp = data.checkpoint;
if (!data.hasMore) break;
}
// Second pass: sync friend events
let friendSinceTimestamp = 0;
while (true) {
const friendBody = JSON.stringify({ sinceTimestamp: friendSinceTimestamp, contextType: 'friend', limit: 100 });
const friendHeaders = buildFederationHeaders(friendBody, peer.hmacSecret, ourOrigin);
const friendResponse = await fetch(`${peer.origin}/api/federation/sync`, {
method: 'POST',
headers: friendHeaders,
body: friendBody,
signal: AbortSignal.timeout(30_000),
});
if (!friendResponse.ok) {
console.error(`[federation-worker] Friend sync with ${peer.origin} failed: ${friendResponse.status}`);
break;
}
const friendData = await friendResponse.json() as { events: FederationRelayEvent[]; hasMore: boolean; checkpoint: number };
if (friendData.events.length === 0) break;
const friendRelayBody = JSON.stringify({
version: 1,
sourceInstance: peer.origin,
events: friendData.events,
});
const friendRelayHeaders = buildFederationHeaders(friendRelayBody, peer.hmacSecret, peer.origin);
await fetch(`${ourOrigin}/api/federation/relay`, {
method: 'POST',
headers: friendRelayHeaders,
body: friendRelayBody,
signal: AbortSignal.timeout(30_000),
});
totalEvents += friendData.events.length;
friendSinceTimestamp = friendData.checkpoint;
if (!friendData.hasMore) break;
}
// Update lastSyncedAt so this doesn't run again
db.update(schema.federationPeers)
.set({ lastSyncedAt: Date.now() })
.where(eq(schema.federationPeers.id, peer.id))
.run();
if (totalEvents > 0) {
console.log(`[federation-worker] Initial sync with ${peer.origin}: ${totalEvents} events synced`);
} else {
console.log(`[federation-worker] Initial sync with ${peer.origin}: no events to sync`);
}
} catch (err) {
console.error(`[federation-worker] Initial sync with ${peer.origin} failed:`, err);
// Don't update lastSyncedAt — will retry next startup
}
}
}
export function startFederationWorkers(): void {
console.log('[federation-worker] Federation workers started');
scheduleOutboxTick();
scheduleFileQueueTick();
scheduleHealthCheckTick();
scheduleJanitorTick();
// Run initial sync for newly peered instances (async, non-blocking)
runInitialSyncForNewPeers().catch((err) => {
console.error('[federation-worker] Initial sync error:', err);
});
}
export function stopFederationWorkers(): void {
if (outboxTimer) {
clearTimeout(outboxTimer);
outboxTimer = null;
}
if (fileQueueTimer) {
clearTimeout(fileQueueTimer);
fileQueueTimer = null;
}
if (healthCheckTimer) {
clearTimeout(healthCheckTimer);
healthCheckTimer = null;
}
if (janitorTimer) {
clearTimeout(janitorTimer);
janitorTimer = null;
}
outboxAbortController?.abort();
outboxAbortController = null;
fileQueueAbortController?.abort();
fileQueueAbortController = null;
healthCheckAbortController?.abort();
healthCheckAbortController = null;
console.log('[federation-worker] Federation workers stopped');
}