Initial commit

This commit is contained in:
root
2026-01-26 08:34:48 +01:00
commit 387314581f
216 changed files with 81204 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* Swarm Discovery
*
* Handles node discovery and announcement in the swarm network.
*/
import { db, nodes, users, posts } from '@/db';
import { eq, sql } from 'drizzle-orm';
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
/**
* Build this node's announcement payload
*/
export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let name = 'Synapsis Node';
let description: string | undefined;
let logoUrl: string | undefined;
let publicKey = '';
let userCount = 0;
let postCount = 0;
let isNsfw = false;
if (db) {
// Get node info
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
if (node) {
name = node.name;
description = node.description ?? undefined;
logoUrl = node.logoUrl ?? undefined;
publicKey = node.publicKey ?? '';
isNsfw = node.isNsfw;
}
// Get counts
const userResult = await db.select({ count: sql<number>`count(*)` }).from(users);
const postResult = await db.select({ count: sql<number>`count(*)` }).from(posts);
userCount = Number(userResult[0]?.count ?? 0);
postCount = Number(postResult[0]?.count ?? 0);
}
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions'];
return {
domain,
name,
description,
logoUrl,
publicKey,
softwareVersion: '0.1.0', // TODO: Get from package.json
userCount,
postCount,
capabilities,
isNsfw,
timestamp: new Date().toISOString(),
};
}
/**
* Announce this node to a remote node
*/
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
try {
const announcement = await buildAnnouncement();
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const url = `${baseUrl}/api/swarm/announce`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(announcement),
});
if (!response.ok) {
const error = await response.text();
await markNodeFailure(targetDomain);
return { success: false, error: `HTTP ${response.status}: ${error}` };
}
// The remote node should respond with their info
const remoteInfo = await response.json() as SwarmNodeInfo;
// Add/update the remote node in our registry
await upsertSwarmNode(remoteInfo, 'direct');
await markNodeSuccess(targetDomain);
return { success: true };
} catch (error) {
await markNodeFailure(targetDomain);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Announce to all seed nodes (bootstrap)
*/
export async function announceToSeeds(): Promise<{
successful: string[];
failed: { domain: string; error: string }[]
}> {
const seeds = await getSeedNodes();
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't announce to ourselves
const targetSeeds = seeds.filter(s => s !== ourDomain);
const successful: string[] = [];
const failed: { domain: string; error: string }[] = [];
for (const seed of targetSeeds) {
const result = await announceToNode(seed);
if (result.success) {
successful.push(seed);
} else {
failed.push({ domain: seed, error: result.error || 'Unknown error' });
}
}
return { successful, failed };
}
/**
* Fetch node info from a remote node
*/
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
try {
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
// Try the swarm endpoint first
let response = await fetch(`${baseUrl}/api/swarm/info`, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
// Fall back to standard node endpoint
response = await fetch(`${baseUrl}/api/node`, {
headers: { 'Accept': 'application/json' },
});
}
if (!response.ok) {
return null;
}
const data = await response.json();
return {
domain: data.domain || domain,
name: data.name,
description: data.description,
logoUrl: data.logoUrl,
publicKey: data.publicKey,
softwareVersion: data.softwareVersion,
userCount: data.userCount,
postCount: data.postCount,
capabilities: data.capabilities,
isNsfw: data.isNsfw,
};
} catch {
return null;
}
}
/**
* Discover a node and add it to the registry
*/
export async function discoverNode(
domain: string,
discoveredVia?: string
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't discover ourselves
if (domain === ourDomain) {
return { success: false, isNew: false, error: 'Cannot discover self' };
}
const info = await fetchNodeInfo(domain);
if (!info) {
return { success: false, isNew: false, error: 'Could not fetch node info' };
}
const result = await upsertSwarmNode(info, discoveredVia);
return { success: true, isNew: result.isNew };
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Swarm Gossip Protocol
*
* Implements epidemic-style gossip for node and handle propagation.
* Nodes periodically exchange their known nodes/handles with random peers.
*/
import { db, handleRegistry } from '@/db';
import { desc, gt } from 'drizzle-orm';
import type { SwarmGossipPayload, SwarmGossipResponse, SwarmSyncResult, SwarmNodeInfo } from './types';
import { SWARM_CONFIG } from './types';
import {
getNodesForGossip,
getActiveSwarmNodes,
getNodesSince,
upsertSwarmNodes,
markNodeSuccess,
markNodeFailure,
logSync,
} from './registry';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { buildAnnouncement } from './discovery';
/**
* Build a gossip payload to send to another node
*/
export async function buildGossipPayload(since?: string): Promise<SwarmGossipPayload> {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Get nodes to share
let nodes: SwarmNodeInfo[];
if (since) {
nodes = await getNodesSince(new Date(since), SWARM_CONFIG.maxNodesPerGossip);
} else {
nodes = await getActiveSwarmNodes(SWARM_CONFIG.maxNodesPerGossip);
}
// Include ourselves in the node list
const announcement = await buildAnnouncement();
const selfNode: SwarmNodeInfo = {
domain: announcement.domain,
name: announcement.name,
description: announcement.description,
logoUrl: announcement.logoUrl,
publicKey: announcement.publicKey,
softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount,
postCount: announcement.postCount,
capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(),
};
// Get handles to share
let handles: SwarmGossipPayload['handles'] = [];
if (db) {
const sinceDate = since ? new Date(since) : undefined;
const handleEntries = await db.query.handleRegistry.findMany({
where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
orderBy: [desc(handleRegistry.updatedAt)],
limit: SWARM_CONFIG.maxHandlesPerGossip,
});
handles = handleEntries.map(h => ({
handle: h.handle,
did: h.did,
nodeDomain: h.nodeDomain,
updatedAt: h.updatedAt?.toISOString(),
}));
}
return {
sender: ourDomain,
nodes: [selfNode, ...nodes],
handles,
timestamp: new Date().toISOString(),
since,
};
}
/**
* Process incoming gossip and return our response
*/
export async function processGossip(
payload: SwarmGossipPayload
): Promise<SwarmGossipResponse> {
const startTime = Date.now();
// Process incoming nodes
const nodeResult = await upsertSwarmNodes(payload.nodes, payload.sender);
// Process incoming handles
let handlesResult = { added: 0, updated: 0 };
if (payload.handles && payload.handles.length > 0) {
handlesResult = await upsertHandleEntries(payload.handles);
}
// Build our response with nodes/handles to share back
const responsePayload = await buildGossipPayload(payload.since);
return {
nodes: responsePayload.nodes,
handles: responsePayload.handles,
received: {
nodes: nodeResult.added + nodeResult.updated,
handles: handlesResult.added + handlesResult.updated,
},
};
}
/**
* Send gossip to a specific node
*/
export async function gossipToNode(
targetDomain: string,
since?: string
): Promise<SwarmSyncResult> {
const startTime = Date.now();
try {
const payload = await buildGossipPayload(since);
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const url = `${baseUrl}/api/swarm/gossip`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const durationMs = Date.now() - startTime;
if (!response.ok) {
const error = `HTTP ${response.status}`;
await markNodeFailure(targetDomain);
await logSync(targetDomain, 'push', {
success: false,
nodesReceived: 0,
nodesSent: payload.nodes.length,
handlesReceived: 0,
handlesSent: payload.handles?.length || 0,
error,
durationMs,
});
return {
success: false,
nodesReceived: 0,
nodesSent: payload.nodes.length,
handlesReceived: 0,
handlesSent: payload.handles?.length || 0,
error,
durationMs,
};
}
const gossipResponse = await response.json() as SwarmGossipResponse;
// Process the response (nodes and handles they sent back)
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain);
let handlesResult = { added: 0, updated: 0 };
if (gossipResponse.handles && gossipResponse.handles.length > 0) {
handlesResult = await upsertHandleEntries(gossipResponse.handles);
}
await markNodeSuccess(targetDomain);
const result: SwarmSyncResult = {
success: true,
nodesReceived: nodeResult.added + nodeResult.updated,
nodesSent: payload.nodes.length,
handlesReceived: handlesResult.added + handlesResult.updated,
handlesSent: payload.handles?.length || 0,
durationMs,
};
await logSync(targetDomain, 'push', result);
return result;
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await markNodeFailure(targetDomain);
const result: SwarmSyncResult = {
success: false,
nodesReceived: 0,
nodesSent: 0,
handlesReceived: 0,
handlesSent: 0,
error: errorMsg,
durationMs,
};
await logSync(targetDomain, 'push', result);
return result;
}
}
/**
* Run a gossip round - contact random nodes and exchange info
*/
export async function runGossipRound(): Promise<{
contacted: number;
successful: number;
totalNodesReceived: number;
totalHandlesReceived: number;
}> {
// Get random nodes to gossip with
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
let contacted = 0;
let successful = 0;
let totalNodesReceived = 0;
let totalHandlesReceived = 0;
for (const target of targets) {
contacted++;
const result = await gossipToNode(target.domain);
if (result.success) {
successful++;
totalNodesReceived += result.nodesReceived;
totalHandlesReceived += result.handlesReceived;
}
}
return {
contacted,
successful,
totalNodesReceived,
totalHandlesReceived,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Swarm Module
*
* The Synapsis swarm is a decentralized network of nodes that discover
* and communicate with each other using a hybrid approach:
*
* 1. Seed nodes (like node.synapsis.social) provide initial bootstrap
* 2. Gossip protocol spreads node/handle information epidemically
* 3. Any node can discover the full network without central authority
* 4. Direct node-to-node interactions (likes, follows, etc.) bypass ActivityPub
*
* Usage:
* - On node startup: call announceToSeeds() to register with the network
* - Periodically: call runGossipRound() to exchange info with peers
* - On demand: call discoverNode() to add a specific node
* - For interactions: use swarm-first delivery with AP fallback
*/
export * from './types';
export * from './registry';
export * from './discovery';
export * from './gossip';
export * from './interactions';
+624
View File
@@ -0,0 +1,624 @@
/**
* Swarm Interactions
*
* Handles direct node-to-node interactions in the swarm network.
* This is the "Swarm-first" approach - we try direct swarm communication
* first, and fall back to ActivityPub for non-Synapsis nodes.
*
* Supported interactions:
* - Likes: Direct like delivery between swarm nodes
* - Reposts: Direct repost/boost delivery
* - Follows: Swarm-native follow relationships
* - Replies: Already implemented in /api/swarm/replies
* - Mentions: Direct mention notifications
*/
import { getActiveSwarmNodes } from './registry';
import type { SwarmNodeInfo } from './types';
// ============================================
// TYPES
// ============================================
export interface SwarmInteraction {
type: 'like' | 'unlike' | 'repost' | 'unrepost' | 'follow' | 'unfollow' | 'mention';
// The actor performing the action
actor: {
handle: string;
displayName: string;
avatarUrl?: string;
nodeDomain: string;
};
// The target of the action
target: {
// For likes/reposts: the post ID and author
postId?: string;
postAuthorHandle?: string;
// For follows: the user being followed
userHandle?: string;
// For mentions: the mentioned user and post context
mentionedHandle?: string;
mentionPostId?: string;
mentionContent?: string;
};
// Metadata
timestamp: string;
interactionId: string; // Unique ID for deduplication
}
export interface SwarmInteractionResponse {
success: boolean;
message?: string;
error?: string;
}
export interface SwarmLikePayload {
postId: string;
like: {
actorHandle: string;
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmUnlikePayload {
postId: string;
unlike: {
actorHandle: string;
actorNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmRepostPayload {
postId: string;
repost: {
actorHandle: string;
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
repostId: string; // The ID of the repost on the actor's node
interactionId: string;
timestamp: string;
};
}
export interface SwarmFollowPayload {
targetHandle: string;
follow: {
followerHandle: string;
followerDisplayName: string;
followerAvatarUrl?: string;
followerBio?: string;
followerNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmUnfollowPayload {
targetHandle: string;
unfollow: {
followerHandle: string;
followerNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmMentionPayload {
mentionedHandle: string;
mention: {
actorHandle: string;
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
postId: string;
postContent: string;
interactionId: string;
timestamp: string;
};
}
// ============================================
// SWARM NODE DETECTION
// ============================================
/**
* Check if a domain is a known Synapsis swarm node
*/
export async function isSwarmNode(domain: string): Promise<boolean> {
const nodes = await getActiveSwarmNodes(500);
return nodes.some(n => n.domain === domain);
}
/**
* Get swarm node info if the domain is a swarm node
*/
export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
const nodes = await getActiveSwarmNodes(500);
return nodes.find(n => n.domain === domain) || null;
}
/**
* Extract domain from a handle (e.g., "user@node.example.com" -> "node.example.com")
*/
export function extractDomainFromHandle(handle: string): string | null {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@');
if (parts.length === 2) {
return parts[1];
}
return null;
}
/**
* Check if a handle belongs to a swarm node
*/
export async function isSwarmHandle(handle: string): Promise<boolean> {
const domain = extractDomainFromHandle(handle);
if (!domain) return false;
return isSwarmNode(domain);
}
// ============================================
// INTERACTION DELIVERY
// ============================================
/**
* Deliver a like to a swarm node
*/
export async function deliverSwarmLike(
targetDomain: string,
payload: SwarmLikePayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/like', payload);
}
/**
* Deliver an unlike to a swarm node
*/
export async function deliverSwarmUnlike(
targetDomain: string,
payload: SwarmUnlikePayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/unlike', payload);
}
/**
* Deliver a repost to a swarm node
*/
export async function deliverSwarmRepost(
targetDomain: string,
payload: SwarmRepostPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/repost', payload);
}
/**
* Deliver a follow to a swarm node
*/
export async function deliverSwarmFollow(
targetDomain: string,
payload: SwarmFollowPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/follow', payload);
}
/**
* Deliver an unfollow to a swarm node
*/
export async function deliverSwarmUnfollow(
targetDomain: string,
payload: SwarmUnfollowPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/unfollow', payload);
}
/**
* Deliver a mention notification to a swarm node
*/
export async function deliverSwarmMention(
targetDomain: string,
payload: SwarmMentionPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/mention', payload);
}
/**
* Generic interaction delivery
*/
async function deliverSwarmInteraction(
targetDomain: string,
endpoint: string,
payload: unknown
): Promise<SwarmInteractionResponse> {
try {
const baseUrl = targetDomain.startsWith('http')
? targetDomain
: targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1')
? `http://${targetDomain}`
: `https://${targetDomain}`;
const url = `${baseUrl}${endpoint}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
const data = await response.json();
return {
success: true,
message: data.message,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ============================================
// PROFILE FETCHING
// ============================================
export interface SwarmUserProfile {
handle: string;
displayName: string;
bio?: string;
avatarUrl?: string;
headerUrl?: string;
website?: string;
followersCount: number;
followingCount: number;
postsCount: number;
createdAt: string;
isBot?: boolean;
nodeDomain: string;
}
export interface SwarmUserPost {
id: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
}
export interface SwarmProfileResponse {
profile: SwarmUserProfile;
posts: SwarmUserPost[];
nodeDomain: string;
timestamp: string;
}
/**
* Fetch a user profile from a swarm node
*/
export async function fetchSwarmUserProfile(
handle: string,
domain: string,
postsLimit: number = 25
): Promise<SwarmProfileResponse | null> {
try {
const baseUrl = domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error(`[Swarm] Failed to fetch profile for ${handle}@${domain}:`, error);
return null;
}
}
/**
* Fetch a single post from a swarm node
*/
export async function fetchSwarmPost(
postId: string,
domain: string
): Promise<SwarmUserPost | null> {
try {
const baseUrl = domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
const url = `${baseUrl}/api/swarm/posts/${postId}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error(`[Swarm] Failed to fetch post ${postId} from ${domain}:`, error);
return null;
}
}
// ============================================
// MENTION DETECTION & DELIVERY
// ============================================
/**
* Extract mentions from post content
* Returns array of { handle, domain } for remote mentions
*/
export function extractMentions(content: string): { handle: string; domain: string | null }[] {
// Match @handle or @handle@domain patterns
const mentionRegex = /@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9.-]+))?/g;
const mentions: { handle: string; domain: string | null }[] = [];
let match;
while ((match = mentionRegex.exec(content)) !== null) {
mentions.push({
handle: match[1].toLowerCase(),
domain: match[2]?.toLowerCase() || null,
});
}
return mentions;
}
/**
* Deliver mention notifications to swarm nodes
*/
export async function deliverSwarmMentions(
content: string,
postId: string,
actor: {
handle: string;
displayName: string;
avatarUrl?: string;
nodeDomain: string;
}
): Promise<{ delivered: number; failed: number }> {
const mentions = extractMentions(content);
let delivered = 0;
let failed = 0;
for (const mention of mentions) {
// Skip local mentions (no domain)
if (!mention.domain) continue;
// Check if it's a swarm node
const isSwarm = await isSwarmNode(mention.domain);
if (!isSwarm) continue;
// Deliver the mention
const result = await deliverSwarmMention(mention.domain, {
mentionedHandle: mention.handle,
mention: {
actorHandle: actor.handle,
actorDisplayName: actor.displayName,
actorAvatarUrl: actor.avatarUrl,
actorNodeDomain: actor.nodeDomain,
postId,
postContent: content,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
delivered++;
} else {
failed++;
}
}
return { delivered, failed };
}
// ============================================
// POST DELIVERY TO SWARM FOLLOWERS
// ============================================
export interface SwarmPostDeliveryPayload {
post: {
id: string;
content: string;
createdAt: string;
isNsfw: boolean;
replyToId?: string;
repostOfId?: string;
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
};
author: {
handle: string;
displayName: string;
avatarUrl?: string;
isNsfw: boolean;
};
nodeDomain: string;
timestamp: string;
}
/**
* Deliver a new post to a swarm node's inbox
* This is used to push posts to followers on other swarm nodes
*/
export async function deliverSwarmPost(
targetDomain: string,
payload: SwarmPostDeliveryPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/inbox', payload);
}
/**
* Get swarm follower domains from remote followers
* Returns domains of swarm nodes that have followers for this user
*/
export async function getSwarmFollowerDomains(userId: string): Promise<string[]> {
try {
const { db, remoteFollowers } = await import('@/db');
const { eq } = await import('drizzle-orm');
if (!db) return [];
const followers = await db.query.remoteFollowers.findMany({
where: eq(remoteFollowers.userId, userId),
});
// Filter for swarm followers (actorUrl starts with swarm://)
const swarmFollowers = followers.filter(f => f.actorUrl.startsWith('swarm://'));
// Extract unique domains
const domains = swarmFollowers.map(f => {
const match = f.actorUrl.match(/^swarm:\/\/([^\/]+)/);
return match ? match[1] : null;
}).filter((d): d is string => d !== null);
return [...new Set(domains)];
} catch (error) {
console.error('[Swarm] Error getting swarm follower domains:', error);
return [];
}
}
/**
* Deliver a post to all swarm followers
*/
export async function deliverPostToSwarmFollowers(
userId: string,
post: {
id: string;
content: string;
createdAt: Date;
isNsfw: boolean;
replyToId?: string | null;
repostOfId?: string | null;
linkPreviewUrl?: string | null;
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
},
author: {
handle: string;
displayName: string | null;
avatarUrl: string | null;
isNsfw: boolean;
},
media: { url: string; mimeType: string | null; altText: string | null }[],
nodeDomain: string
): Promise<{ delivered: number; failed: number }> {
const swarmDomains = await getSwarmFollowerDomains(userId);
if (swarmDomains.length === 0) {
return { delivered: 0, failed: 0 };
}
const payload: SwarmPostDeliveryPayload = {
post: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isNsfw: post.isNsfw,
replyToId: post.replyToId || undefined,
repostOfId: post.repostOfId || undefined,
media: media.length > 0 ? media.map(m => ({
url: m.url,
mimeType: m.mimeType || undefined,
altText: m.altText || undefined,
})) : undefined,
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
},
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isNsfw: author.isNsfw,
},
nodeDomain,
timestamp: new Date().toISOString(),
};
let delivered = 0;
let failed = 0;
// Deliver to all swarm domains in parallel
const results = await Promise.allSettled(
swarmDomains.map(domain => deliverSwarmPost(domain, payload))
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.success) {
delivered++;
} else {
failed++;
}
}
return { delivered, failed };
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Swarm Registry
*
* Manages the local registry of known swarm nodes.
*/
import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
/**
* Get or create a swarm node entry
*/
export async function upsertSwarmNode(
node: SwarmNodeInfo,
discoveredVia?: string
): Promise<{ isNew: boolean }> {
if (!db) {
return { isNew: false };
}
const existing = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, node.domain),
});
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
if (!existing) {
await db.insert(swarmNodes).values({
domain: node.domain,
name: node.name,
description: node.description,
logoUrl: node.logoUrl,
publicKey: node.publicKey,
softwareVersion: node.softwareVersion,
userCount: node.userCount,
postCount: node.postCount,
isNsfw: node.isNsfw ?? false,
discoveredVia,
capabilities,
lastSeenAt: node.lastSeenAt ? new Date(node.lastSeenAt) : new Date(),
});
return { isNew: true };
}
// Update existing node
await db.update(swarmNodes)
.set({
name: node.name ?? existing.name,
description: node.description ?? existing.description,
logoUrl: node.logoUrl ?? existing.logoUrl,
publicKey: node.publicKey ?? existing.publicKey,
softwareVersion: node.softwareVersion ?? existing.softwareVersion,
userCount: node.userCount ?? existing.userCount,
postCount: node.postCount ?? existing.postCount,
isNsfw: node.isNsfw ?? existing.isNsfw,
capabilities: capabilities ?? existing.capabilities,
lastSeenAt: new Date(),
consecutiveFailures: 0,
isActive: true,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, node.domain));
return { isNew: false };
}
/**
* Bulk upsert swarm nodes from gossip
*/
export async function upsertSwarmNodes(
nodes: SwarmNodeInfo[],
discoveredVia: string
): Promise<{ added: number; updated: number }> {
if (!db || nodes.length === 0) {
return { added: 0, updated: 0 };
}
let added = 0;
let updated = 0;
// Filter out our own domain
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
for (const node of filteredNodes) {
const result = await upsertSwarmNode(node, discoveredVia);
if (result.isNew) {
added++;
} else {
updated++;
}
}
return { added, updated };
}
/**
* Get all active swarm nodes
*/
export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]> {
if (!db) {
return [];
}
const nodes = await db.query.swarmNodes.findMany({
where: eq(swarmNodes.isActive, true),
orderBy: [desc(swarmNodes.lastSeenAt)],
limit,
});
return nodes.map(nodeToInfo);
}
/**
* Get nodes for gossip (random selection of active nodes)
*/
export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]> {
if (!db) {
return [];
}
// Get active nodes with decent trust scores, ordered randomly
const nodes = await db.query.swarmNodes.findMany({
where: and(
eq(swarmNodes.isActive, true),
gt(swarmNodes.trustScore, 20)
),
orderBy: sql`RANDOM()`,
limit: count,
});
return nodes.map(nodeToInfo);
}
/**
* Get nodes updated since a timestamp (for incremental sync)
*/
export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNodeInfo[]> {
if (!db) {
return [];
}
const nodes = await db.query.swarmNodes.findMany({
where: gt(swarmNodes.updatedAt, since),
orderBy: [desc(swarmNodes.updatedAt)],
limit,
});
return nodes.map(nodeToInfo);
}
/**
* Mark a node as having failed contact
*/
export async function markNodeFailure(domain: string): Promise<void> {
if (!db) return;
const node = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, domain),
});
if (!node) return;
const newFailures = node.consecutiveFailures + 1;
const newTrust = Math.max(
SWARM_CONFIG.minTrustScore,
node.trustScore + SWARM_CONFIG.trustScoreOnFailure
);
const isActive = newFailures < SWARM_CONFIG.maxConsecutiveFailures;
await db.update(swarmNodes)
.set({
consecutiveFailures: newFailures,
trustScore: newTrust,
isActive,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, domain));
}
/**
* Mark a node as successfully contacted
*/
export async function markNodeSuccess(domain: string): Promise<void> {
if (!db) return;
const node = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, domain),
});
if (!node) return;
const newTrust = Math.min(
SWARM_CONFIG.maxTrustScore,
node.trustScore + SWARM_CONFIG.trustScoreOnSuccess
);
await db.update(swarmNodes)
.set({
consecutiveFailures: 0,
trustScore: newTrust,
isActive: true,
lastSeenAt: new Date(),
lastSyncAt: new Date(),
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, domain));
}
/**
* Log a sync operation
*/
export async function logSync(
remoteDomain: string,
direction: 'push' | 'pull',
result: SwarmSyncResult
): Promise<void> {
if (!db) return;
await db.insert(swarmSyncLog).values({
remoteDomain,
direction,
nodesReceived: result.nodesReceived,
nodesSent: result.nodesSent,
handlesReceived: result.handlesReceived,
handlesSent: result.handlesSent,
success: result.success,
errorMessage: result.error,
durationMs: result.durationMs,
});
}
/**
* Get seed nodes (with fallback to defaults)
*/
export async function getSeedNodes(): Promise<string[]> {
if (!db) {
return [...DEFAULT_SEED_NODES];
}
const seeds = await db.query.swarmSeeds.findMany({
where: eq(swarmSeeds.isEnabled, true),
orderBy: [swarmSeeds.priority],
});
if (seeds.length === 0) {
return [...DEFAULT_SEED_NODES];
}
return seeds.map(s => s.domain);
}
/**
* Add a seed node
*/
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
if (!db) return;
await db.insert(swarmSeeds)
.values({ domain, priority })
.onConflictDoUpdate({
target: swarmSeeds.domain,
set: { priority, isEnabled: true },
});
}
/**
* Get swarm statistics
*/
export async function getSwarmStats() {
if (!db) {
return {
totalNodes: 0,
activeNodes: 0,
totalUsers: 0,
totalPosts: 0,
};
}
const allNodes = await db.query.swarmNodes.findMany();
const activeNodes = allNodes.filter(n => n.isActive);
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
return {
totalNodes: allNodes.length,
activeNodes: activeNodes.length,
totalUsers,
totalPosts,
};
}
// Helper to convert DB node to SwarmNodeInfo
function nodeToInfo(node: typeof swarmNodes.$inferSelect): SwarmNodeInfo {
return {
domain: node.domain,
name: node.name ?? undefined,
description: node.description ?? undefined,
logoUrl: node.logoUrl ?? undefined,
publicKey: node.publicKey ?? undefined,
softwareVersion: node.softwareVersion ?? undefined,
userCount: node.userCount ?? undefined,
postCount: node.postCount ?? undefined,
capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined,
isNsfw: node.isNsfw,
lastSeenAt: node.lastSeenAt.toISOString(),
};
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Swarm Timeline
*
* Fetches and aggregates posts from across the swarm network.
*/
import { getActiveSwarmNodes } from './registry';
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
interface TimelineResult {
posts: SwarmPost[];
sources: { domain: string; postCount: number; filteredCount?: number; isNsfw?: boolean; error?: string }[];
fetchedAt: string;
}
interface TimelineOptions {
includeNsfw?: boolean; // Whether to include NSFW content
}
/**
* Extract the first URL from post content
*/
function extractFirstUrl(content: string): string | null {
const urlMatch = content.match(/https?:\/\/[^\s<>"{}|\\^`[\]]+/);
if (!urlMatch) return null;
// Clean trailing punctuation
return urlMatch[0].replace(/[)\].,!?;:]+$/, '');
}
/**
* Fetch link preview for a URL
*/
async function fetchLinkPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
const response = await fetch(previewUrl, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return {
url: data.url || url,
title: data.title || null,
description: data.description || null,
image: data.image || null,
};
} catch {
return null;
}
}
/**
* Enrich swarm posts with link previews if they have URLs but no preview data
*/
async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]> {
const enrichmentPromises = posts.map(async (post) => {
// Skip if already has link preview data
if (post.linkPreviewUrl) return post;
// Extract URL from content
const url = extractFirstUrl(post.content);
if (!url) return post;
// Skip video URLs (handled by VideoEmbed component)
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
// Fetch preview
const preview = await fetchLinkPreview(url);
if (!preview) return post;
return {
...post,
linkPreviewUrl: preview.url,
linkPreviewTitle: preview.title || undefined,
linkPreviewDescription: preview.description || undefined,
linkPreviewImage: preview.image || undefined,
};
});
return Promise.all(enrichmentPromises);
}
/**
* Fetch timeline from a single node
*/
async function fetchNodeTimeline(
domain: string,
limit: number = 20
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
try {
// Determine protocol - use http for localhost, https for everything else
let baseUrl: string;
if (domain.startsWith('http')) {
baseUrl = domain;
} else if (domain.startsWith('localhost') || domain.startsWith('127.0.0.1')) {
baseUrl = `http://${domain}`;
} else {
baseUrl = `https://${domain}`;
}
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return { posts: [], error: `HTTP ${response.status}` };
}
const data = await response.json();
return { posts: data.posts || [], nodeIsNsfw: data.nodeIsNsfw };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { posts: [], error: message };
}
}
/**
* Fetch aggregated timeline from the swarm
*
* Queries multiple nodes in parallel and merges results.
* Filters out NSFW content unless explicitly requested.
*/
export async function fetchSwarmTimeline(
maxNodes: number = 10,
postsPerNode: number = 10,
options: TimelineOptions = {}
): Promise<TimelineResult> {
const { includeNsfw = false } = options;
// Get active nodes to query
const nodes = await getActiveSwarmNodes(maxNodes);
// Always include our own posts
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Always query all nodes - we filter posts, not nodes
const nodesToQuery = [
ourDomain,
...nodes.map(n => n.domain).filter(d => d !== ourDomain)
].slice(0, maxNodes);
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}`);
// Fetch from all nodes in parallel
const results = await Promise.all(
nodesToQuery.map(async (domain) => {
const result = await fetchNodeTimeline(domain, postsPerNode);
return {
domain,
...result,
};
})
);
// Collect all posts and track sources
const allPosts: SwarmPost[] = [];
const sources: TimelineResult['sources'] = [];
for (const result of results) {
// Filter NSFW posts only if user doesn't want NSFW content
// A post is NSFW if it's explicitly marked OR comes from an NSFW node
const filteredPosts = includeNsfw
? result.posts
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
// Log filtering details for debugging
if (!includeNsfw && result.posts.length > 0) {
const nsfwPosts = result.posts.filter(p => p.isNsfw);
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter`);
}
sources.push({
domain: result.domain,
postCount: result.posts.length,
filteredCount: filteredPosts.length,
isNsfw: result.nodeIsNsfw,
error: result.error,
});
allPosts.push(...filteredPosts);
}
// Sort by createdAt descending and dedupe by id
const seen = new Set<string>();
const uniquePosts = allPosts
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.filter(post => {
const key = `${post.nodeDomain}:${post.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// Enrich posts that have URLs but no link preview data
const enrichedPosts = await enrichPostsWithPreviews(uniquePosts);
return {
posts: enrichedPosts,
sources,
fetchedAt: new Date().toISOString(),
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Swarm Types
*
* Type definitions for the Synapsis swarm network.
*/
export interface SwarmNodeInfo {
domain: string;
name?: string;
description?: string;
logoUrl?: string;
publicKey?: string;
softwareVersion?: string;
userCount?: number;
postCount?: number;
capabilities?: SwarmCapability[];
isNsfw?: boolean;
lastSeenAt?: string;
}
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions';
export interface SwarmAnnouncement {
domain: string;
name: string;
description?: string;
logoUrl?: string;
publicKey: string;
softwareVersion: string;
userCount: number;
postCount: number;
capabilities: SwarmCapability[];
isNsfw: boolean;
timestamp: string;
signature?: string; // Signed with node's private key
}
export interface SwarmGossipPayload {
// The node sending this gossip
sender: string;
// Nodes this sender knows about
nodes: SwarmNodeInfo[];
// Optional: handles to sync (piggyback on gossip)
handles?: {
handle: string;
did: string;
nodeDomain: string;
updatedAt?: string;
}[];
// Timestamp for freshness
timestamp: string;
// Since parameter for incremental sync
since?: string;
}
export interface SwarmGossipResponse {
// Nodes we're sharing back
nodes: SwarmNodeInfo[];
// Handles we're sharing back
handles?: {
handle: string;
did: string;
nodeDomain: string;
updatedAt?: string;
}[];
// Stats about what we received
received: {
nodes: number;
handles: number;
};
}
export interface SwarmSyncResult {
success: boolean;
nodesReceived: number;
nodesSent: number;
handlesReceived: number;
handlesSent: number;
error?: string;
durationMs: number;
}
export interface SwarmStats {
totalNodes: number;
activeNodes: number;
totalUsers: number;
totalPosts: number;
lastUpdated: string;
}
// Default seed nodes for bootstrapping
export const DEFAULT_SEED_NODES = [
'node.synapsis.social',
] as const;
// Swarm configuration
export const SWARM_CONFIG = {
// How often to run gossip (in ms)
gossipIntervalMs: 5 * 60 * 1000, // 5 minutes
// How many nodes to gossip with per round
gossipFanout: 3,
// Max nodes to include in a single gossip message
maxNodesPerGossip: 100,
// Max handles to include in a single gossip message
maxHandlesPerGossip: 500,
// How long before a node is considered inactive
inactiveThresholdMs: 24 * 60 * 60 * 1000, // 24 hours
// How many consecutive failures before marking inactive
maxConsecutiveFailures: 5,
// Trust score adjustments
trustScoreOnSuccess: 1,
trustScoreOnFailure: -5,
minTrustScore: 0,
maxTrustScore: 100,
defaultTrustScore: 50,
} as const;