peerpigeon
Version:
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
1,277 lines (1,072 loc) • 47.7 kB
JavaScript
import { EventEmitter } from './EventEmitter.js';
import { safeSetInterval, safeClearInterval, safeSetTimeout, safeClearTimeout } from './TimerUtils.js';
import DebugLogger from './DebugLogger.js';
/**
* WebDHT - A Kademlia-like Distributed Hash Table for WebRTC mesh networks
*
* Features:
* - Key-value storage with closest peer routing
* - Subscribe/unsubscribe to key changes
* - Automatic replication and fault tolerance
* - XOR distance-based routing
*/
export class WebDHT extends EventEmitter {
constructor(mesh) {
super();
this.debug = DebugLogger.create('WebDHT');
this.mesh = mesh;
this.peerId = mesh.peerId;
// Local storage for key-value pairs this peer is responsible for
this.localStorage = new Map();
// Subscriptions: key -> Set of peer IDs that want notifications
this.subscriptions = new Map();
// Pending subscriptions for keys that don't exist yet: keyId -> Set of peer IDs
this.pendingSubscriptions = new Map();
// Active subscriptions from this peer to others
this.mySubscriptions = new Set();
// Routing table for faster lookups (k-buckets)
this.routingTable = new Map();
// Configuration
this.config = {
k: 20, // Bucket size (number of peers per bucket)
alpha: 3, // Parallelism factor for lookups
replicationFactor: 3, // Number of peers to store each key
ttl: null, // Default TTL: null = no expiration (records persist indefinitely)
refreshInterval: 300000 // Refresh interval (5 minutes)
};
// Setup message handlers
this.setupMessageHandlers();
// Start periodic maintenance
this.startMaintenance();
this.debug.log(`WebDHT initialized for peer ${this.peerId.substring(0, 8)}...`);
}
/**
* Setup message handlers for DHT operations
*/
setupMessageHandlers() {
this.debug.log(`🔥 DHT Setting up message handlers for peer ${this.peerId.substring(0, 8)}`);
// DHT messages now come through ConnectionManager's handleIncomingMessage
// which routes 'dht' type messages to this.mesh.webDHT.handleMessage
// So we don't need to listen to gossip manager events anymore
this.debug.log(`🔥 DHT Message handlers setup complete for peer ${this.peerId.substring(0, 8)}`);
}
/**
* Handle incoming DHT messages - called by ConnectionManager
*/
async handleMessage(message, fromPeerId) {
this.debug.log(`🔥 DHT Message received from ${fromPeerId.substring(0, 8)}:`, message);
// Extract the DHT operation type and data
const type = message.messageType || message.type;
const data = message.data;
if (!type || !data) {
this.debug.error(`🔥 DHT Invalid message structure from ${fromPeerId.substring(0, 8)}:`, message);
return;
}
this.debug.log(`🔥 DHT Processing ${type} from ${fromPeerId.substring(0, 8)}, data:`, data);
try {
switch (type) {
case 'store':
this.debug.log(`🔥 DHT Handling store request from ${fromPeerId.substring(0, 8)}`);
await this.handleStoreRequest(fromPeerId, data);
break;
case 'find_value':
this.debug.log(`🔥 DHT Handling find_value request from ${fromPeerId.substring(0, 8)}`);
await this.handleFindValueRequest(fromPeerId, data);
break;
case 'find_node':
this.debug.log(`🔥 DHT Handling find_node request from ${fromPeerId.substring(0, 8)}`);
await this.handleFindNodeRequest(fromPeerId, data);
break;
case 'subscribe':
this.debug.log(`🔥 DHT Handling subscribe request from ${fromPeerId.substring(0, 8)}`);
await this.handleSubscribeRequest(fromPeerId, data);
break;
case 'unsubscribe':
this.debug.log(`🔥 DHT Handling unsubscribe request from ${fromPeerId.substring(0, 8)}`);
await this.handleUnsubscribeRequest(fromPeerId, data);
break;
case 'value_changed':
this.debug.log(`🔥 DHT Handling value_changed notification from ${fromPeerId.substring(0, 8)}`);
await this.handleValueChangedNotification(fromPeerId, data);
break;
case 'update_value':
this.debug.log(`🔥 DHT Handling update_value request from ${fromPeerId.substring(0, 8)}`);
await this.handleUpdateValueRequest(fromPeerId, data);
break;
case 'store_response':
case 'find_value_response':
case 'find_node_response':
case 'update_value_response':
this.debug.log(`🔥 DHT Handling response (${type}) from ${fromPeerId.substring(0, 8)}`);
this.handleResponse(fromPeerId, data);
break;
default:
this.debug.warn(`Unknown DHT message type: ${type}`);
}
} catch (error) {
this.debug.error(`🔥 DHT Error handling message type ${type} from ${fromPeerId.substring(0, 8)}:`, error);
}
}
/**
* Store a key-value pair in the DHT
*/
async put(key, value, options = {}) {
const keyId = await this.generateKeyId(key);
const ttl = options.ttl || this.config.ttl;
this.debug.log(`DHT PUT: ${key} -> ${keyId.substring(0, 8)}...`);
const storeData = {
key,
keyId,
value,
timestamp: Date.now(),
ttl,
publisher: this.peerId
};
// Check if this is a new key (for pending subscription activation)
const isNewKey = !this.localStorage.has(keyId);
// CRITICAL FIX: Always store locally first (the originator always keeps a copy)
this.storeLocally(keyId, storeData);
this.debug.log(`DHT PUT: Stored locally: ${key}`);
// If this is a new key, activate any pending subscriptions
if (isNewKey) {
const activatedSubscribers = this.activatePendingSubscriptions(keyId);
// Notify locally activated subscribers about the new key
if (activatedSubscribers.length > 0) {
this.debug.log(`DHT PUT: Notifying ${activatedSubscribers.length} locally pending subscribers about new key ${key}`);
const notificationPromises = activatedSubscribers.map(async (subscriberId) => {
if (subscriberId !== this.peerId) {
try {
return await this.sendDHTMessage(subscriberId, 'value_changed', {
key,
keyId,
newValue: value,
timestamp: storeData.timestamp,
isNewKey: true
});
} catch (error) {
this.debug.warn(`Failed to notify subscriber ${subscriberId.substring(0, 8)} about new key:`, error.message);
return null;
}
}
}).filter(Boolean);
await Promise.allSettled(notificationPromises);
}
}
// AUTO-SUBSCRIBE: When putting a value, automatically subscribe to future changes
this.mySubscriptions.add(keyId);
this.debug.log(`DHT PUT: Auto-subscribed to ${key} for future updates`);
// Find the closest peers to store this key for replication
const closestPeers = await this.findClosestPeers(keyId, this.config.k); // Get more peers initially
// Implement iterative storage for better replication success
return await this.performIterativePut(keyId, storeData, closestPeers);
}
/**
* Retrieve a value from the DHT
*/
async get(key, options = {}) {
const keyId = await this.generateKeyId(key);
// Subscribe unless explicitly disabled or skipSubscribe is true
const subscribe = options.skipSubscribe ? false : (options.subscribe !== false);
const forceRefresh = options.forceRefresh || false;
this.debug.log(`DHT GET: ${key} -> ${keyId.substring(0, 8)}... (auto-subscribing: ${subscribe}, forceRefresh: ${forceRefresh})`);
// Check local storage FIRST (unless force refresh is requested)
if (!forceRefresh && this.localStorage.has(keyId)) {
const data = this.localStorage.get(keyId);
if (!this.isExpired(data)) {
this.debug.log(`DHT GET: Found locally: ${key} (using cached data)`);
// Add subscription even for local data (unless skipSubscribe is true)
if (subscribe) {
this.mySubscriptions.add(keyId);
}
return data.value;
} else {
// Remove expired data
this.localStorage.delete(keyId);
this.debug.log(`DHT GET: Removed expired local data for: ${key}`);
}
}
if (forceRefresh) {
this.debug.log('DHT GET: Force refresh requested - bypassing local cache, fetching from original storing peers');
} else {
this.debug.log(`DHT GET: Not found locally, routing to network for key: ${key}`);
}
// Implement iterative Kademlia lookup with automatic subscription
return await this.performIterativeGet(keyId, key, subscribe);
}
/**
* Subscribe to changes for a key
* NOTE: This now supports subscribing to keys that don't exist yet
*/
async subscribe(key) {
const keyId = await this.generateKeyId(key);
this.debug.log(`DHT EXPLICIT SUBSCRIBE: ${key} -> ${keyId.substring(0, 8)}...`);
// Add to our subscriptions immediately
this.mySubscriptions.add(keyId);
// Find the peers responsible for storing this key
const closestPeers = await this.findClosestPeers(keyId, this.config.replicationFactor);
// Send subscription requests to all potential storage peers
const subscribePromises = closestPeers.map(async (peerId) => {
if (peerId !== this.peerId) {
try {
return await this.sendDHTMessage(peerId, 'subscribe', {
keyId,
key,
subscriber: this.peerId
});
} catch (error) {
this.debug.warn(`DHT SUBSCRIBE: Failed to subscribe to peer ${peerId.substring(0, 8)}:`, error.message);
return null;
}
}
}).filter(Boolean);
await Promise.allSettled(subscribePromises);
// Also add ourselves to pending subscriptions if we're among the closest
if (this.isAmongClosest(keyId, closestPeers)) {
this.addPendingSubscription(keyId, this.peerId);
}
// Try to get the current value (if it exists)
let currentValue = null;
try {
currentValue = await this.get(key, { skipSubscribe: true }); // Skip auto-subscribe since we're already subscribed
} catch (error) {
this.debug.log(`DHT SUBSCRIBE: Key ${key} doesn't exist yet, but subscription is active for when it's created`);
}
this.debug.log(`DHT SUBSCRIBE: Subscribed to ${key}, current value:`, currentValue);
return currentValue;
}
/**
* Unsubscribe from changes for a key
*/
async unsubscribe(key) {
const keyId = await this.generateKeyId(key);
if (!this.mySubscriptions.has(keyId)) {
return;
}
// Find the peers responsible for this key and unsubscribe
const closestPeers = await this.findClosestPeers(keyId, this.config.replicationFactor);
const unsubscribePromises = closestPeers.map(async (peerId) => {
if (peerId !== this.peerId) {
return this.sendDHTMessage(peerId, 'unsubscribe', {
keyId,
key,
subscriber: this.peerId
});
}
}).filter(Boolean);
await Promise.allSettled(unsubscribePromises);
this.mySubscriptions.delete(keyId);
// Remove local subscription if we're storing this key
if (this.subscriptions.has(keyId)) {
this.subscriptions.get(keyId).delete(this.peerId);
if (this.subscriptions.get(keyId).size === 0) {
this.subscriptions.delete(keyId);
}
}
// Remove local pending subscription if we're storing this key
if (this.pendingSubscriptions.has(keyId)) {
this.pendingSubscriptions.get(keyId).delete(this.peerId);
if (this.pendingSubscriptions.get(keyId).size === 0) {
this.pendingSubscriptions.delete(keyId);
}
}
this.debug.log(`DHT UNSUBSCRIBE: ${key} -> ${keyId.substring(0, 8)}...`);
}
/**
* Update a key's value and notify all replicas and subscribers
*/
async update(key, newValue, options = {}) {
const keyId = await this.generateKeyId(key);
this.debug.log(`DHT UPDATE: Updating ${key} with new value across all replicas and subscribers`);
// AUTO-SUBSCRIBE: When updating a value, automatically subscribe to future changes
this.mySubscriptions.add(keyId);
this.debug.log(`DHT UPDATE: Auto-subscribed to ${key} for future updates`);
// Find all peers that should store this key (closest peers)
const closestPeers = await this.findClosestPeers(keyId, this.config.replicationFactor);
// Create update data
const updateData = {
key,
keyId,
value: newValue,
timestamp: Date.now(),
ttl: options.ttl || this.config.ttl,
publisher: this.peerId
};
this.debug.log(`DHT UPDATE: Target replica peers for ${key}:`, closestPeers.map(p => p.substring(0, 8)));
// CRITICAL FIX: Always update locally first if we're among the closest
let localUpdateSuccess = false;
if (this.isAmongClosest(keyId, closestPeers)) {
this.storeLocally(keyId, updateData);
localUpdateSuccess = true;
this.debug.log(`DHT UPDATE: Updated ${key} locally (we are replica peer)`);
}
// CRITICAL FIX: Send update to ALL replica peers and wait for ALL to succeed
const updatePromises = closestPeers.map(async (peerId) => {
if (peerId !== this.peerId) {
try {
this.debug.log(`DHT UPDATE: Sending update to replica peer ${peerId.substring(0, 8)}`);
const response = await this.sendDHTMessage(peerId, 'update_value', updateData);
if (response && response.success) {
this.debug.log(`DHT UPDATE: Successfully updated replica peer ${peerId.substring(0, 8)}`);
return { peerId, success: true };
} else {
this.debug.warn(`DHT UPDATE: Replica peer ${peerId.substring(0, 8)} rejected update:`, response);
return { peerId, success: false, response };
}
} catch (error) {
this.debug.warn(`DHT UPDATE: Failed to update replica peer ${peerId.substring(0, 8)}:`, error.message);
return { peerId, success: false, error: error.message };
}
}
return null;
}).filter(Boolean);
const results = await Promise.allSettled(updatePromises);
const updateResults = results
.filter(r => r.status === 'fulfilled' && r.value)
.map(r => r.value);
const successfulUpdates = updateResults.filter(r => r.success).length;
const failedUpdates = updateResults.filter(r => !r.success);
this.debug.log(`DHT UPDATE: Replica peer update results for ${key}:`);
this.debug.log(` - Successful: ${successfulUpdates}`);
this.debug.log(` - Failed: ${failedUpdates.length}`);
this.debug.log(` - Local: ${localUpdateSuccess ? 'success' : 'not applicable'}`);
// ENHANCED: Log failed updates for debugging
if (failedUpdates.length > 0) {
this.debug.warn('DHT UPDATE: Failed to update these replica peers:',
failedUpdates.map(f => ({ peer: f.peerId.substring(0, 8), reason: f.error || f.response })));
this.debug.warn('DHT UPDATE: This may lead to consistency issues. Consider implementing retry logic.');
}
// CRITICAL: Broadcast value change to ALL peers (including non-replicas)
// This ensures any peer with cached values gets the update immediately
await this.broadcastValueChange(key, keyId, newValue, updateData.timestamp);
// SUCCESS CRITERIA: ALL replica peers must be updated for consistency
// This prevents the case where a later GET operation hits a replica peer with stale data
const totalSuccessful = (localUpdateSuccess ? 1 : 0) + successfulUpdates;
const totalReplicas = closestPeers.length;
const wasSuccessful = totalSuccessful >= totalReplicas;
if (!wasSuccessful) {
this.debug.warn(`DHT UPDATE: ${key} update FAILED - only ${totalSuccessful}/${totalReplicas} replicas updated successfully`);
this.debug.warn('DHT UPDATE: This may cause consistency issues where GET operations return stale data');
} else {
this.debug.log(`DHT UPDATE: ${key} update SUCCESSFUL - all ${totalSuccessful}/${totalReplicas} replicas updated`);
}
return wasSuccessful;
}
/**
* Broadcast value change to ALL peers in the network
* This ensures both subscribers and peers with cached values are updated
*/
async broadcastValueChange(key, keyId, newValue, timestamp) {
// Get ALL connected peers, not just subscribers
const allPeers = this.mesh.connectionManager.getConnectedPeers().map(p => p.peerId);
this.debug.log(`DHT UPDATE: Broadcasting value change for ${key} to ${allPeers.length} peers`);
const notificationPromises = allPeers.map(async (peerId) => {
try {
// Send notification to ALL peers - they'll decide if they care
return await this.sendDHTMessage(peerId, 'value_changed', {
key,
keyId,
newValue,
timestamp
});
} catch (error) {
this.debug.warn(`DHT BROADCAST: Failed to notify peer ${peerId.substring(0, 8)}:`, error.message);
return null;
}
});
const results = await Promise.allSettled(notificationPromises);
const successfulNotifications = results.filter(r => r.status === 'fulfilled' && r.value).length;
this.debug.log(`DHT UPDATE: Broadcasted ${key} change to ${successfulNotifications}/${allPeers.length} peers`);
// Also emit local event if this peer has subscriptions
if (this.mySubscriptions.has(keyId)) {
this.emit('valueChanged', { key, keyId, newValue, timestamp });
}
}
/**
* Handle store request from another peer
*/
async handleStoreRequest(fromPeerId, data) {
const { keyId, key, value, timestamp, ttl, publisher, messageId } = data;
this.debug.log(`🔥 DHT STORE REQUEST: ${key} from ${fromPeerId.substring(0, 8)}, messageId: ${messageId}`);
// Check if we should store this key (are we close enough?)
const closestPeers = await this.findClosestPeers(keyId, this.config.replicationFactor);
if (this.isAmongClosest(keyId, closestPeers)) {
// Check if this is a new key (for pending subscription activation)
const isNewKey = !this.localStorage.has(keyId);
this.storeLocally(keyId, { key, value, timestamp, ttl, publisher });
// If this is a new key, activate any pending subscriptions
if (isNewKey) {
const activatedSubscribers = this.activatePendingSubscriptions(keyId);
// Notify newly activated subscribers about the new key
if (activatedSubscribers.length > 0) {
this.debug.log(`DHT STORE: Notifying ${activatedSubscribers.length} subscribers about new key ${key}`);
const notificationPromises = activatedSubscribers.map(async (subscriberId) => {
if (subscriberId !== this.peerId && subscriberId !== fromPeerId) {
try {
return await this.sendDHTMessage(subscriberId, 'value_changed', {
key,
keyId,
newValue: value,
timestamp,
isNewKey: true
});
} catch (error) {
this.debug.warn(`Failed to notify subscriber ${subscriberId.substring(0, 8)} about new key:`, error.message);
return null;
}
}
}).filter(Boolean);
await Promise.allSettled(notificationPromises);
}
}
this.debug.log(`🔥 DHT STORE: Accepting and storing ${key}, sending success response`);
// Send response using the same direct messaging structure
this.sendDirectToPeer(fromPeerId, {
type: 'dht',
messageType: 'store_response',
data: {
keyId,
success: true,
storedBy: this.peerId,
messageId // Echo back the messageId for response matching
}
});
this.debug.log(`DHT STORE: accepted ${key} from ${fromPeerId.substring(0, 8)}...`);
} else {
this.debug.log(`🔥 DHT STORE: Rejecting ${key} (not among closest), sending failure response`);
this.sendDirectToPeer(fromPeerId, {
type: 'dht',
messageType: 'store_response',
data: {
keyId,
success: false,
reason: 'not_closest',
closestPeers: closestPeers.slice(0, this.config.replicationFactor),
messageId // Echo back the messageId for response matching
}
});
}
}
/**
* Handle find value request
*/
async handleFindValueRequest(fromPeerId, data) {
const { keyId, key, subscribe, requester, messageId } = data;
this.debug.log(`🔥 DHT FIND_VALUE REQUEST: ${key} from ${fromPeerId.substring(0, 8)}, messageId: ${messageId}`);
this.debug.log('🔥 DHT FIND_VALUE REQUEST: Full data received:', data);
if (this.localStorage.has(keyId)) {
const storedData = this.localStorage.get(keyId);
if (!this.isExpired(storedData)) {
this.debug.log(`🔥 DHT FIND_VALUE: Found ${key} locally, sending success response with messageId: ${messageId}`);
// Add subscription if requested
if (subscribe) {
this.addSubscription(keyId, requester);
}
// CRITICAL FIX: Use the request's messageId, not generate a new one
const responseData = {
keyId,
found: true,
messageId, // Echo back the EXACT messageId from request
key: storedData.key,
value: storedData.value,
timestamp: storedData.timestamp,
ttl: storedData.ttl,
publisher: storedData.publisher
};
this.debug.log(`🔥 DHT FIND_VALUE: Sending response data with messageId ${messageId}:`, responseData);
this.sendDirectToPeer(fromPeerId, {
type: 'dht',
messageType: 'find_value_response',
data: responseData
});
this.debug.log(`DHT FIND_VALUE: found ${key} for ${fromPeerId.substring(0, 8)}...`);
return;
} else {
this.localStorage.delete(keyId);
this.debug.log(`🔥 DHT FIND_VALUE: Found ${key} but expired, removed from storage`);
}
}
this.debug.log(`🔥 DHT FIND_VALUE: ${key} not found locally, sending closest peers response with messageId: ${messageId}`);
// If not found, return closest peers
const closestPeers = await this.findClosestPeers(keyId, this.config.k);
const responseData = {
keyId,
found: false,
messageId, // Echo back the EXACT messageId from request
closestPeers
};
this.debug.log(`🔥 DHT FIND_VALUE: Sending not-found response data with messageId ${messageId}:`, responseData);
this.sendDirectToPeer(fromPeerId, {
type: 'dht',
messageType: 'find_value_response',
data: responseData
});
}
/**
* Handle subscribe request
*/
async handleSubscribeRequest(fromPeerId, data) {
const { keyId, key, subscriber } = data;
this.debug.log(`DHT SUBSCRIBE REQUEST: ${key} from ${fromPeerId.substring(0, 8)} for subscriber ${subscriber.substring(0, 8)}`);
if (this.localStorage.has(keyId)) {
// Key exists - add to active subscriptions
this.addSubscription(keyId, subscriber);
this.debug.log(`DHT SUBSCRIBE: added ${subscriber.substring(0, 8)}... to ${key} notifications (key exists)`);
} else {
// Key doesn't exist yet - add to pending subscriptions
this.addPendingSubscription(keyId, subscriber);
this.debug.log(`DHT SUBSCRIBE: added ${subscriber.substring(0, 8)}... to ${key} pending notifications (key doesn't exist yet)`);
}
}
/**
* Handle unsubscribe request
*/
async handleUnsubscribeRequest(fromPeerId, data) {
const { keyId, key, subscriber } = data;
// Remove from active subscriptions
if (this.subscriptions.has(keyId)) {
this.subscriptions.get(keyId).delete(subscriber);
if (this.subscriptions.get(keyId).size === 0) {
this.subscriptions.delete(keyId);
}
this.debug.log(`DHT UNSUBSCRIBE: removed ${subscriber.substring(0, 8)}... from ${key} active notifications`);
}
// Remove from pending subscriptions
if (this.pendingSubscriptions.has(keyId)) {
this.pendingSubscriptions.get(keyId).delete(subscriber);
if (this.pendingSubscriptions.get(keyId).size === 0) {
this.pendingSubscriptions.delete(keyId);
}
this.debug.log(`DHT UNSUBSCRIBE: removed ${subscriber.substring(0, 8)}... from ${key} pending notifications`);
}
}
/**
* Handle value changed notification
*/
async handleValueChangedNotification(fromPeerId, data) {
const { key, keyId, newValue, timestamp, isNewKey } = data;
this.debug.log(`🔥 DHT VALUE_CHANGED notification: ${key} = ${newValue} from ${fromPeerId.substring(0, 8)}${isNewKey ? ' (NEW KEY)' : ''}`);
// CRITICAL FIX: Always update local cache if we have it, regardless of subscriptions
// This prevents stale cached values from being returned by get() operations
if (this.localStorage.has(keyId)) {
const storedData = this.localStorage.get(keyId);
// Only update if the new timestamp is newer (prevent old notifications from overwriting newer data)
if (!storedData.timestamp || timestamp >= storedData.timestamp) {
storedData.value = newValue;
storedData.timestamp = timestamp;
this.debug.log(`🔥 DHT VALUE_CHANGED: Updated local cache for ${key} (timestamp: ${timestamp})`);
} else {
this.debug.log(`🔥 DHT VALUE_CHANGED: Ignoring older notification for ${key} (current: ${storedData.timestamp}, notification: ${timestamp})`);
}
} else if (isNewKey) {
// For new keys, store the data locally even if we don't normally store this key
// This helps with caching and consistency
this.debug.log(`🔥 DHT VALUE_CHANGED: Caching new key ${key} locally`);
this.storeLocally(keyId, {
key,
value: newValue,
timestamp,
ttl: null, // Use default TTL
publisher: fromPeerId
}, false); // Not a primary replica
}
// Check if we have subscribers for this key
const hasSubscribers = this.mySubscriptions.has(keyId);
const hasLocalSubscribers = this.subscriptions.has(keyId) && this.subscriptions.get(keyId).size > 0;
if (hasSubscribers || hasLocalSubscribers) {
this.debug.log(`🔥 DHT VALUE_CHANGED: Processing notification for ${key} (mySubscriptions: ${hasSubscribers}, localSubscribers: ${hasLocalSubscribers})`);
// Emit change event if this peer is subscribed
if (hasSubscribers) {
this.emit('valueChanged', { key, keyId, newValue, timestamp, isNewKey });
this.debug.log(`🔥 DHT VALUE_CHANGED: Emitted local event for ${key}${isNewKey ? ' (NEW KEY)' : ''}`);
}
// Forward to local subscribers (other peers that subscribed through this peer)
if (hasLocalSubscribers) {
const localSubscribers = Array.from(this.subscriptions.get(keyId));
this.debug.log(`🔥 DHT VALUE_CHANGED: Forwarding to ${localSubscribers.length} local subscribers for ${key}`);
const forwardPromises = localSubscribers.map(async (subscriberId) => {
if (subscriberId !== this.peerId && subscriberId !== fromPeerId) {
try {
return await this.sendDHTMessage(subscriberId, 'value_changed', {
key,
keyId,
newValue,
timestamp,
isNewKey
});
} catch (error) {
this.debug.warn(`Failed to forward notification to ${subscriberId.substring(0, 8)}:`, error.message);
return null;
}
}
}).filter(Boolean);
await Promise.allSettled(forwardPromises);
}
} else {
this.debug.log(`🔥 DHT VALUE_CHANGED: No subscriptions for ${key}, but updated cache anyway`);
}
}
/**
* Handle update value request - used to propagate updates to replica peers
*/
async handleUpdateValueRequest(fromPeerId, data) {
const { keyId, key, value, timestamp, ttl, publisher, messageId } = data;
this.debug.log(`🔥 DHT UPDATE_VALUE REQUEST: ${key} from ${fromPeerId.substring(0, 8)}`);
// Check if we should store this key (are we among the closest?)
const closestPeers = await this.findClosestPeers(keyId, this.config.replicationFactor);
if (this.isAmongClosest(keyId, closestPeers)) {
// Update our local copy
this.storeLocally(keyId, { key, value, timestamp, ttl, publisher });
this.debug.log(`DHT UPDATE_VALUE: Updated ${key} locally`);
// Notify any local subscribers
if (this.subscriptions.has(keyId)) {
const subscribers = Array.from(this.subscriptions.get(keyId));
this.debug.log(`DHT UPDATE_VALUE: Notifying ${subscribers.length} local subscribers for ${key}`);
const notificationPromises = subscribers.map(async (subscriberId) => {
if (subscriberId !== this.peerId) {
try {
return await this.sendDHTMessage(subscriberId, 'value_changed', {
key,
keyId,
newValue: value,
timestamp
});
} catch (error) {
this.debug.warn(`Failed to notify subscriber ${subscriberId.substring(0, 8)}:`, error.message);
return null;
}
}
}).filter(Boolean);
await Promise.allSettled(notificationPromises);
}
// Send success response
this.sendDirectToPeer(fromPeerId, {
type: 'dht',
messageType: 'update_value_response',
data: {
keyId,
success: true,
updatedBy: this.peerId,
messageId
}
});
} else {
this.debug.log(`DHT UPDATE_VALUE: Not responsible for ${key}, rejecting update`);
// Send failure response
this.sendDirectToPeer(fromPeerId, {
type: 'dht',
messageType: 'update_value_response',
data: {
keyId,
success: false,
reason: 'not_closest',
messageId
}
});
}
}
/**
* Send a DHT message to a specific peer using direct P2P connection
*/
async sendDHTMessage(targetPeerId, type, data) {
return new Promise((resolve, reject) => {
const messageId = this.generateMessageId();
this.debug.log(`🔥 DHT Sending ${type} to ${targetPeerId.substring(0, 8)} with messageId ${messageId}`);
this.debug.log('🔥 DHT Request data being sent:', { ...data, messageId });
// Set up response handler
const timeout = safeSetTimeout(() => {
this.debug.log(`🔥 DHT Timeout for messageId ${messageId} to peer ${targetPeerId.substring(0, 8)}`);
this.removeResponseHandler(messageId);
reject(new Error('DHT message timeout'));
}, 5000);
this.setResponseHandler(messageId, (response) => {
this.debug.log(`🔥 DHT Response received for ${type} messageId ${messageId}:`, response);
safeClearTimeout(timeout);
resolve(response);
});
// Send message directly via peer-to-peer connection with proper message structure
const messageToSend = {
type: 'dht', // This tells ConnectionManager to route to DHT
data: { ...data, messageId },
messageType: type // The actual DHT operation type
};
this.debug.log(`🔥 DHT Full message being sent to ${targetPeerId.substring(0, 8)}:`, messageToSend);
const success = this.sendDirectToPeer(targetPeerId, messageToSend);
if (!success) {
this.debug.log(`🔥 DHT Failed to send to ${targetPeerId.substring(0, 8)}, cleaning up messageId ${messageId}`);
safeClearTimeout(timeout);
this.removeResponseHandler(messageId);
reject(new Error('Failed to send DHT message - no direct connection'));
}
});
}
/**
* Send a message directly to a peer using the data channel or route through mesh
* This implements proper Kademlia routing for peers that aren't directly connected
*/
sendDirectToPeer(targetPeerId, message) {
this.debug.log(`DHT: Attempting to send message to ${targetPeerId.substring(0, 8)}:`, message.type);
// First try direct connection if available
const success = this.mesh.connectionManager.sendDirectMessage(targetPeerId, message);
if (success) {
this.debug.log(`DHT: Successfully sent direct message to ${targetPeerId.substring(0, 8)}`);
return true;
} else {
this.debug.log(`DHT: No direct connection to ${targetPeerId.substring(0, 8)}, using Kademlia routing`);
// Use gossip/routing mechanism to reach the target peer
// Create a special DHT routing message that will be forwarded through the mesh
const routingMessage = {
id: this.generateMessageId(),
from: this.peerId,
to: targetPeerId, // Target peer ID for routing
subtype: 'dht-routing', // Special subtype for DHT routing
content: message, // The actual DHT message to deliver
timestamp: Date.now(),
ttl: 5, // Allow up to 5 hops to reach the target
path: [this.peerId] // Track routing path
};
this.debug.log(`DHT: Routing message to ${targetPeerId.substring(0, 8)} via gossip network`);
this.mesh.gossipManager.propagateMessage(routingMessage);
return true;
}
}
/**
* Find the closest peers to a given key ID
*/
async findClosestPeers(keyId, count) {
const allPeers = [this.peerId, ...this.mesh.connectionManager.getConnectedPeers().map(p => p.peerId)];
this.debug.log(`DHT findClosestPeers: Looking for ${count} closest peers to key ${keyId.substring(0, 8)}... from ${allPeers.length} total peers`);
// Sort by XOR distance to the key
allPeers.sort((a, b) => {
const distA = this.xorDistance(keyId, a);
const distB = this.xorDistance(keyId, b);
return distA.localeCompare(distB);
});
const result = allPeers.slice(0, count);
this.debug.log('DHT findClosestPeers result:', result.map(peerId => ({
peerId: peerId.substring(0, 8) + '...',
distance: this.xorDistance(keyId, peerId).substring(0, 8) + '...',
isSelf: peerId === this.peerId
})));
return result;
}
/**
* Calculate XOR distance between two IDs
*/
xorDistance(id1, id2) {
// Convert hex strings to numbers and XOR them
let distance = '';
for (let i = 0; i < Math.min(id1.length, id2.length); i++) {
const xor = parseInt(id1[i], 16) ^ parseInt(id2[i], 16);
distance += xor.toString(16);
}
return distance;
}
/**
* Generate a deterministic key ID from a key string using SHA-1
*/
async generateKeyId(key) {
// Use Web Crypto API to generate SHA-1 hash like peer IDs
const encoder = new TextEncoder();
const data = encoder.encode(key);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = new Uint8Array(hashBuffer);
return Array.from(hashArray, byte => byte.toString(16).padStart(2, '0')).join('');
}
/**
* Generate a unique message ID
*/
generateMessageId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
/**
* Store data locally
*/
storeLocally(keyId, data, isPrimary = true) {
this.localStorage.set(keyId, {
...data,
storedAt: Date.now(),
isPrimary
});
}
/**
* Add a subscription for a key
*/
addSubscription(keyId, subscriberId) {
if (!this.subscriptions.has(keyId)) {
this.subscriptions.set(keyId, new Set());
}
this.subscriptions.get(keyId).add(subscriberId);
}
/**
* Add a pending subscription for a key that doesn't exist yet
*/
addPendingSubscription(keyId, subscriberId) {
if (!this.pendingSubscriptions.has(keyId)) {
this.pendingSubscriptions.set(keyId, new Set());
}
this.pendingSubscriptions.get(keyId).add(subscriberId);
}
/**
* Activate pending subscriptions when a key is first created
*/
activatePendingSubscriptions(keyId) {
if (this.pendingSubscriptions.has(keyId)) {
const pendingSubscribers = this.pendingSubscriptions.get(keyId);
// Move all pending subscribers to active subscriptions
if (!this.subscriptions.has(keyId)) {
this.subscriptions.set(keyId, new Set());
}
pendingSubscribers.forEach(subscriberId => {
this.subscriptions.get(keyId).add(subscriberId);
});
this.debug.log(`DHT: Activated ${pendingSubscribers.size} pending subscriptions for keyId ${keyId.substring(0, 8)}`);
// Clear pending subscriptions for this key
this.pendingSubscriptions.delete(keyId);
return Array.from(pendingSubscribers);
}
return [];
}
/**
* Check if this peer is among the closest to a key
*/
isAmongClosest(keyId, closestPeers) {
return closestPeers.includes(this.peerId);
}
/**
* Check if stored data has expired
*/
isExpired(data) {
// If TTL is null/undefined, record never expires
if (!data.ttl) {
return false;
}
// TTL is stored in seconds, so convert to milliseconds
return data.timestamp + (data.ttl * 1000) < Date.now();
}
/**
* Response handler management
*/
setResponseHandler(messageId, handler) {
if (!this.responseHandlers) {
this.responseHandlers = new Map();
}
this.responseHandlers.set(messageId, handler);
}
removeResponseHandler(messageId) {
if (this.responseHandlers) {
this.responseHandlers.delete(messageId);
}
}
handleResponse(fromPeerId, data) {
this.debug.log(`🔥 DHT Response received from ${fromPeerId.substring(0, 8)}: messageId=${data.messageId}, type=${data.type || 'unknown'}`);
this.debug.log('🔥 DHT Response full data:', data);
this.debug.log('🔥 DHT Response available handlers:', this.responseHandlers ? Array.from(this.responseHandlers.keys()) : 'none');
if (this.responseHandlers && data.messageId) {
const handler = this.responseHandlers.get(data.messageId);
if (handler) {
this.debug.log(`🔥 DHT Response: Found handler for messageId ${data.messageId}, calling handler`);
this.removeResponseHandler(data.messageId);
handler(data);
} else {
this.debug.warn(`🔥 DHT Response: No handler found for messageId ${data.messageId}`);
this.debug.log('🔥 DHT Response: Available handlers:', Array.from(this.responseHandlers.keys()));
}
} else {
this.debug.warn('🔥 DHT Response: Missing responseHandlers or messageId', {
hasHandlers: !!this.responseHandlers,
messageId: data.messageId
});
}
}
/**
* Start periodic maintenance tasks
*/
startMaintenance() {
// Clean up expired keys
// Use safeSetInterval to avoid issues with wrapped setInterval functions
this.maintenanceInterval = safeSetInterval(() => {
this.performMaintenance();
}, this.config.refreshInterval);
}
/**
* Perform maintenance tasks
*/
performMaintenance() {
// Remove expired keys
for (const [keyId, data] of this.localStorage.entries()) {
if (this.isExpired(data)) {
this.localStorage.delete(keyId);
this.subscriptions.delete(keyId);
this.debug.log(`DHT MAINTENANCE: removed expired key ${keyId.substring(0, 8)}...`);
}
}
// TODO: Republish keys if needed
// TODO: Update routing table
this.debug.log(`DHT MAINTENANCE: ${this.localStorage.size} keys stored, ${this.subscriptions.size} subscriptions active`);
}
/**
* Get DHT statistics
*/
getStats() {
return {
storedKeys: this.localStorage.size,
activeSubscriptions: this.subscriptions.size,
pendingSubscriptions: this.pendingSubscriptions.size,
mySubscriptions: this.mySubscriptions.size,
connectedPeers: this.mesh.connectionManager.getConnectedPeerCount()
};
}
/**
* Cleanup DHT resources
*/
cleanup() {
if (this.maintenanceInterval) {
safeClearInterval(this.maintenanceInterval);
this.maintenanceInterval = null;
}
this.localStorage.clear();
this.subscriptions.clear();
this.pendingSubscriptions.clear();
this.mySubscriptions.clear();
if (this.responseHandlers) {
this.responseHandlers.clear();
}
this.debug.log('WebDHT cleaned up');
}
/**
* Perform iterative Kademlia lookup for GET operations
* This improves success rate by querying multiple rounds of peers
*/
async performIterativeGet(keyId, key, subscribe = false) {
const queriedPeers = new Set([this.peerId]);
const closestPeers = await this.findClosestPeers(keyId, this.config.k);
// Remove self from the list
const availablePeers = closestPeers.filter(peerId => peerId !== this.peerId);
this.debug.log(`DHT Iterative GET: Found ${availablePeers.length} potential peers for key ${key}`);
// Try querying peers in rounds, increasing the number each round
const maxRounds = 3;
let currentRound = 0;
let peersPerRound = this.config.alpha; // Start with alpha peers
while (currentRound < maxRounds && queriedPeers.size < availablePeers.length + 1) {
currentRound++;
// Get the next batch of peers to query
const peersToQuery = availablePeers
.filter(peerId => !queriedPeers.has(peerId))
.slice(0, peersPerRound);
if (peersToQuery.length === 0) {
this.debug.log(`DHT Iterative GET Round ${currentRound}: No more peers to query`);
break;
}
this.debug.log(`DHT Iterative GET Round ${currentRound}: Querying ${peersToQuery.length} peers`);
// Mark these peers as queried
peersToQuery.forEach(peerId => queriedPeers.add(peerId));
// Query this batch of peers in parallel
const queryPromises = peersToQuery.map(async (peerId) => {
try {
return await this.sendDHTMessage(peerId, 'find_value', {
keyId,
key,
subscribe,
requester: this.peerId
});
} catch (error) {
this.debug.warn(`DHT GET: Failed to query peer ${peerId.substring(0, 8)}:`, error.message);
return null;
}
});
const results = await Promise.allSettled(queryPromises);
// Collect all valid responses from this round
const validResponses = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled' && result.value) {
const data = result.value;
if (data.found && !this.isExpired(data)) {
validResponses.push({
peerId: peersToQuery[i],
data,
timestamp: data.timestamp || 0
});
this.debug.log(`DHT Iterative GET: Found value on peer ${peersToQuery[i].substring(0, 8)} with timestamp ${data.timestamp}, value: ${JSON.stringify(data.value).substring(0, 50)}...`);
}
}
}
// If we found any valid responses, find the most recent one
if (validResponses.length > 0) {
// Sort by timestamp descending to get the most recent value
validResponses.sort((a, b) => b.timestamp - a.timestamp);
const mostRecent = validResponses[0];
this.debug.log(`DHT Iterative GET: Selected most recent value from peer ${mostRecent.peerId.substring(0, 8)} (timestamp: ${mostRecent.timestamp}) in round ${currentRound}`);
if (validResponses.length > 1) {
this.debug.log(`DHT Iterative GET: Found ${validResponses.length} versions, timestamps: ${validResponses.map(r => r.timestamp).join(', ')}`);
// Log inconsistency warning if timestamps are very different
const timeDiffs = validResponses.map(r => Math.abs(mostRecent.timestamp - r.timestamp));
const maxTimeDiff = Math.max(...timeDiffs);
if (maxTimeDiff > 10000) { // More than 10 seconds difference
this.debug.warn(`DHT CONSISTENCY WARNING: Large timestamp differences found (max: ${maxTimeDiff}ms). This suggests replica peers have inconsistent data!`);
this.debug.warn('DHT CONSISTENCY: Detailed responses:', validResponses.map(r => ({
peer: r.peerId.substring(0, 8),
timestamp: r.timestamp,
value: JSON.stringify(r.data.value).substring(0, 30) + '...'
})));
}
}
// Cache the most recent value locally
this.storeLocally(keyId, mostRecent.data, false);
// ALWAYS add subscription when we successfully get a value
if (subscribe) {
this.mySubscriptions.add(keyId);
this.debug.log(`DHT GET: Auto-subscribed to ${key}`);
}
return mostRecent.data.value;
}
// Increase peers per round for next iteration (more aggressive search)
peersPerRound = Math.min(peersPerRound * 2, Math.max(1, Math.floor(availablePeers.length / maxRounds)));
}
this.debug.log(`DHT Iterative GET failed: key ${key} not found after querying ${queriedPeers.size - 1} peers`);
return null;
}
/**
* Perform iterative storage for PUT operations with better replication
*/
async performIterativePut(keyId, storeData, closestPeers) {
const targetReplicas = this.config.replicationFactor;
const availablePeers = closestPeers.filter(peerId => peerId !== this.peerId);
this.debug.log(`DHT Iterative PUT: Attempting to store ${storeData.key} on ${targetReplicas} of ${availablePeers.length} available peers`);
let successful = 0;
let attempted = 0;
const batchSize = Math.min(this.config.alpha, availablePeers.length);
// Try to store on peers in batches until we reach target replication
for (let i = 0; i < availablePeers.length && successful < targetReplicas; i += batchSize) {
const batch = availablePeers.slice(i, i + batchSize);
attempted += batch.length;
this.debug.log(`DHT PUT Batch ${Math.floor(i / batchSize) + 1}: Storing on ${batch.length} peers`);
const storePromises = batch.map(async (peerId) => {
try {
const result = await this.sendDHTMessage(peerId, 'store', storeData);
return { peerId, success: true, result };
} catch (error) {
this.debug.warn(`DHT PUT: Failed to store on peer ${peerId.substring(0, 8)}:`, error.message);
return { peerId, success: false, error };
}
});
const results = await Promise.allSettled(storePromises);
// Count successful stores in this batch
const batchSuccessful = results.filter(r =>
r.status === 'fulfilled' && r.value && r.value.success
).length;
successful += batchSuccessful;
this.debug.log(`DHT PUT Batch completed: ${batchSuccessful}/${batch.length} successful (total: ${successful}/${targetReplicas})`);
// If we've reached our target, we can stop
if (successful >= targetReplicas) {
break;
}
// Small delay between batches to avoid overwhelming the network
if (i + batchSize < availablePeers.length) {
await new Promise(resolve => safeSetTimeout(resolve, 100));
}
}
this.debug.log(`DHT PUT completed: ${successful}/${targetReplicas} target replicas stored (${attempted} peers attempted)`);
return successful > 0;
}
}