|
| 1 | +import Gun from "gun"; |
| 2 | +import "gun/lib/webrtc.js"; |
| 3 | +import { HostCapabilities } from "../interfaces"; |
| 4 | + |
| 5 | +export interface GunRegistryOptions { |
| 6 | + peers?: string[]; |
| 7 | + namespace?: string; |
| 8 | + /** |
| 9 | + * When true, registration will aggressively override any existing values |
| 10 | + * for the same storeId by first clearing known fields before writing fresh ones. |
| 11 | + * This helps when restarting a host with a fixed storeId and avoids stale data lingering. |
| 12 | + */ |
| 13 | + forceOverride?: boolean; |
| 14 | + /** |
| 15 | + * Optional delay (ms) between clearing old fields and writing fresh values, |
| 16 | + * giving the network a moment to propagate deletes. Defaults to 150ms. |
| 17 | + */ |
| 18 | + overrideDelayMs?: number; |
| 19 | + /** |
| 20 | + * WebRTC configuration for peer-to-peer connections |
| 21 | + * When enabled, mesh networking is automatic |
| 22 | + */ |
| 23 | + webrtc?: { |
| 24 | + iceServers?: Array<{ urls: string | string[] }>; |
| 25 | + }; |
| 26 | + /** |
| 27 | + * Enable local storage for offline capabilities |
| 28 | + */ |
| 29 | + localStorage?: boolean; |
| 30 | +} |
| 31 | + |
| 32 | +interface GunInstance { |
| 33 | + get: (key: string) => GunChain; |
| 34 | +} |
| 35 | + |
| 36 | +interface GunChain { |
| 37 | + get: (key: string) => GunChain; |
| 38 | + put: (data: Record<string, unknown> | null) => void; |
| 39 | + once: (callback: (data: Record<string, unknown>) => void) => void; |
| 40 | + on: (callback: (data: Record<string, unknown>) => void) => void; |
| 41 | +} |
| 42 | + |
| 43 | +export class GunRegistry { |
| 44 | + private gun: GunInstance | null = null; |
| 45 | + private options: GunRegistryOptions; |
| 46 | + private isGunAvailable: boolean = false; |
| 47 | + |
| 48 | + constructor(options: GunRegistryOptions = {}) { |
| 49 | + this.options = { |
| 50 | + peers: options.peers || ["http://nostalgiagame.go.ro:30878/gun"], |
| 51 | + namespace: options.namespace || "dig-nat-tools", |
| 52 | + forceOverride: options.forceOverride ?? true, |
| 53 | + overrideDelayMs: options.overrideDelayMs ?? 150, |
| 54 | + // webrtc: options.webrtc || { |
| 55 | + // iceServers: [ |
| 56 | + // { urls: 'stun:stun.l.google.com:19302' }, |
| 57 | + // { urls: 'stun:stun1.l.google.com:19302' }, |
| 58 | + // { urls: 'stun:stun2.l.google.com:19302' } |
| 59 | + // ] |
| 60 | + // }, |
| 61 | + // localStorage: options.localStorage ?? true, |
| 62 | + }; |
| 63 | + |
| 64 | + this.initializeGun(); |
| 65 | + } |
| 66 | + |
| 67 | + private initializeGun(): void { |
| 68 | + try { |
| 69 | + this.gun = Gun({ |
| 70 | + peers: this.options.peers, |
| 71 | + file: undefined, // Disable local file storage (must be string or undefined) |
| 72 | + localStorage: false, // Use option or default to false |
| 73 | + radisk: false, // Disable radisk storage |
| 74 | + axe: false, |
| 75 | + }); |
| 76 | + this.isGunAvailable = true; |
| 77 | + console.log("Gun.js registry initialized with WebRTC and mesh networking"); |
| 78 | + console.log(`🔧 WebRTC enabled with ${this.options.webrtc?.iceServers?.length || 0} ICE servers`); |
| 79 | + console.log(`🔧 Mesh networking: enabled (automatic with WebRTC)`); |
| 80 | + console.log(`🔧 Local storage: ${this.options.localStorage ? 'enabled' : 'disabled'}`); |
| 81 | + } catch { |
| 82 | + console.warn("Gun.js not available, peer discovery will not work"); |
| 83 | + this.isGunAvailable = false; |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + public isAvailable(): boolean { |
| 88 | + return this.isGunAvailable; |
| 89 | + } |
| 90 | + |
| 91 | + public async register(capabilities: HostCapabilities): Promise<void> { |
| 92 | + if (!this.isGunAvailable || !this.gun) { |
| 93 | + throw new Error("Gun.js registry not available"); |
| 94 | + } |
| 95 | + |
| 96 | + if (!capabilities.storeId) { |
| 97 | + throw new Error("StoreId is required for registration"); |
| 98 | + } |
| 99 | + |
| 100 | + console.log(`🔧 [GunRegistry] Starting registration for host: ${capabilities.storeId}`); |
| 101 | + console.log(`🔧 [GunRegistry] Using namespace: ${this.options.namespace}`); |
| 102 | + console.log(`🔧 [GunRegistry] Peers configured: ${JSON.stringify(this.options.peers)}`); |
| 103 | + |
| 104 | + // Create a flattened structure that Gun.js can handle |
| 105 | + const flatEntry = { |
| 106 | + storeId: capabilities.storeId, |
| 107 | + lastSeen: Date.now(), |
| 108 | + directHttp_available: capabilities.directHttp?.available || false, |
| 109 | + directHttp_ip: capabilities.directHttp?.ip || "", |
| 110 | + directHttp_port: capabilities.directHttp?.port || 0, |
| 111 | + webTorrent_available: capabilities.webTorrent?.available || false, |
| 112 | + webTorrent_magnetUris: capabilities.webTorrent?.magnetUris ? JSON.stringify(capabilities.webTorrent.magnetUris) : "[]", |
| 113 | + }; |
| 114 | + |
| 115 | + console.log(`🔧 [GunRegistry] Registration data:`, JSON.stringify(flatEntry, null, 2)); |
| 116 | + |
| 117 | + try { |
| 118 | + const hostRef = this.gun |
| 119 | + .get(this.options.namespace!) |
| 120 | + .get(capabilities.storeId); |
| 121 | + |
| 122 | + // Optionally clear known fields to ensure our fresh values win on restart |
| 123 | + if (this.options.forceOverride) { |
| 124 | + try { |
| 125 | + const fieldsToClear = [ |
| 126 | + "directHttp_available", |
| 127 | + "directHttp_ip", |
| 128 | + "directHttp_port", |
| 129 | + "webTorrent_available", |
| 130 | + "webTorrent_magnetUris", |
| 131 | + "externalIp", |
| 132 | + "port", |
| 133 | + "lastSeen", |
| 134 | + "storeId", |
| 135 | + ]; |
| 136 | + fieldsToClear.forEach((k) => hostRef.get(k).put(null)); |
| 137 | + const delay = Math.max(0, this.options.overrideDelayMs || 0); |
| 138 | + if (delay > 0) { |
| 139 | + await new Promise((r) => setTimeout(r, delay)); |
| 140 | + } |
| 141 | + } catch (e) { |
| 142 | + console.warn("⚠️ [GunRegistry] Failed clearing existing fields before override:", e); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // Store in Gun.js |
| 147 | + hostRef.put(flatEntry); |
| 148 | + |
| 149 | + console.log(`✅ [GunRegistry] Successfully registered host ${capabilities.storeId} in Gun.js registry`); |
| 150 | + |
| 151 | + } catch (error) { |
| 152 | + console.error(`❌ [GunRegistry] Registration failed for ${capabilities.storeId}:`, error); |
| 153 | + throw error; |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + public async findPeer(storeId: string): Promise<HostCapabilities | null> { |
| 158 | + if (!this.isGunAvailable || !this.gun) { |
| 159 | + throw new Error("Gun.js registry not available"); |
| 160 | + } |
| 161 | + |
| 162 | + console.log(`🔍 [GunRegistry] Looking for specific peer: ${storeId}`); |
| 163 | + |
| 164 | + return new Promise((resolve) => { |
| 165 | + const timeout = setTimeout(() => { |
| 166 | + console.log(`⏰ [GunRegistry] Timeout searching for peer ${storeId}`); |
| 167 | + resolve(null); |
| 168 | + }, 10000); // 10 second timeout |
| 169 | + |
| 170 | + this.gun!.get(this.options.namespace!) |
| 171 | + .get(storeId) |
| 172 | + .once((data: Record<string, unknown>) => { |
| 173 | + clearTimeout(timeout); |
| 174 | + console.log(`📊 [GunRegistry] Peer ${storeId} data:`, data); |
| 175 | + |
| 176 | + if (data && data.storeId === storeId) { |
| 177 | + // Filter out stale entries (older than 5 minutes) |
| 178 | + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; |
| 179 | + const lastSeen = data.lastSeen as number; |
| 180 | + |
| 181 | + console.log(`🕒 [GunRegistry] Peer ${storeId} last seen: ${lastSeen ? new Date(lastSeen).toLocaleString() : 'never'}`); |
| 182 | + |
| 183 | + if (lastSeen && lastSeen > fiveMinutesAgo) { |
| 184 | + console.log(`✅ [GunRegistry] Peer ${storeId} is fresh`); |
| 185 | + |
| 186 | + // Reconstruct the capabilities object |
| 187 | + const capabilities: HostCapabilities = { |
| 188 | + storeId: data.storeId as string, |
| 189 | + directHttp: data.directHttp_available ? { |
| 190 | + available: data.directHttp_available as boolean, |
| 191 | + ip: data.directHttp_ip as string, |
| 192 | + port: data.directHttp_port as number |
| 193 | + } : undefined, |
| 194 | + webTorrent: data.webTorrent_available ? { |
| 195 | + available: data.webTorrent_available as boolean, |
| 196 | + magnetUris: data.webTorrent_magnetUris ? |
| 197 | + JSON.parse(data.webTorrent_magnetUris as string) : [] |
| 198 | + } : undefined, |
| 199 | + // Legacy fields for backward compatibility |
| 200 | + externalIp: data.externalIp as string, |
| 201 | + port: data.port as number, |
| 202 | + lastSeen: lastSeen |
| 203 | + }; |
| 204 | + |
| 205 | + resolve(capabilities); |
| 206 | + } else { |
| 207 | + console.log(`⏰ [GunRegistry] Peer ${storeId} is stale`); |
| 208 | + resolve(null); |
| 209 | + } |
| 210 | + } else { |
| 211 | + console.log(`❌ [GunRegistry] Peer ${storeId} not found or invalid data`); |
| 212 | + resolve(null); |
| 213 | + } |
| 214 | + }); |
| 215 | + }); |
| 216 | + } |
| 217 | + |
| 218 | + public async findAvailablePeers(): Promise<HostCapabilities[]> { |
| 219 | + if (!this.isGunAvailable || !this.gun) { |
| 220 | + throw new Error("Gun.js registry not available"); |
| 221 | + } |
| 222 | + |
| 223 | + console.log(`🔍 [GunRegistry] Searching for peers in namespace: ${this.options.namespace}`); |
| 224 | + console.log(`🔍 [GunRegistry] Connected to peers: ${JSON.stringify(this.options.peers)}`); |
| 225 | + |
| 226 | + return new Promise((resolve) => { |
| 227 | + const peers: HostCapabilities[] = []; |
| 228 | + const timeout = setTimeout(() => { |
| 229 | + console.log(`⏰ [GunRegistry] Search timeout reached, found ${peers.length} peers`); |
| 230 | + resolve(peers); |
| 231 | + }, 30000); // Increase timeout to 10 seconds |
| 232 | + |
| 233 | + this.gun!.get(this.options.namespace!) |
| 234 | + .once(async (data: Record<string, unknown>) => { |
| 235 | + console.log(`📊 [GunRegistry] Raw hosts data received:`, data); |
| 236 | + |
| 237 | + if (data) { |
| 238 | + const allKeys = Object.keys(data); |
| 239 | + console.log(`🔑 [GunRegistry] All keys in hosts data:`, allKeys); |
| 240 | + |
| 241 | + const hostKeys = allKeys.filter(key => key !== "_"); |
| 242 | + console.log(`🏠 [GunRegistry] Host keys (excluding Gun.js metadata):`, hostKeys); |
| 243 | + |
| 244 | + // Process each host key by fetching the actual data |
| 245 | + let processedHosts = 0; |
| 246 | + const totalHosts = hostKeys.length; |
| 247 | + |
| 248 | + if (totalHosts === 0) { |
| 249 | + console.log(`❌ [GunRegistry] No hosts found in namespace ${this.options.namespace}`); |
| 250 | + clearTimeout(timeout); |
| 251 | + resolve(peers); |
| 252 | + return; |
| 253 | + } |
| 254 | + |
| 255 | + for (const hostKey of hostKeys) { |
| 256 | + console.log(`🔍 [GunRegistry] Fetching detailed data for host: ${hostKey}`); |
| 257 | + |
| 258 | + // Fetch the actual host data by following the reference |
| 259 | + this.gun!.get(this.options.namespace!) |
| 260 | + .get(hostKey) |
| 261 | + .once((hostData: Record<string, unknown>) => { |
| 262 | + processedHosts++; |
| 263 | + console.log(`� [GunRegistry] Host ${hostKey} detailed data:`, hostData); |
| 264 | + |
| 265 | + if (hostData && hostData.storeId && typeof hostData.storeId === 'string') { |
| 266 | + // Filter out stale entries (older than 5 minutes) |
| 267 | + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; |
| 268 | + const lastSeen = hostData.lastSeen as number; |
| 269 | + |
| 270 | + console.log(`🕒 [GunRegistry] Host ${hostKey} last seen: ${lastSeen ? new Date(lastSeen).toLocaleString() : 'never'}`); |
| 271 | + console.log(`🕒 [GunRegistry] Five minutes ago: ${new Date(fiveMinutesAgo).toLocaleString()}`); |
| 272 | + |
| 273 | + if (lastSeen && lastSeen > fiveMinutesAgo) { |
| 274 | + console.log(`✅ [GunRegistry] Host ${hostKey} is fresh, adding to results`); |
| 275 | + |
| 276 | + // Reconstruct the capabilities object |
| 277 | + const capabilities: HostCapabilities = { |
| 278 | + storeId: hostData.storeId as string, |
| 279 | + directHttp: hostData.directHttp_available ? { |
| 280 | + available: hostData.directHttp_available as boolean, |
| 281 | + ip: hostData.directHttp_ip as string, |
| 282 | + port: hostData.directHttp_port as number |
| 283 | + } : undefined, |
| 284 | + webTorrent: hostData.webTorrent_available ? { |
| 285 | + available: hostData.webTorrent_available as boolean, |
| 286 | + magnetUris: hostData.webTorrent_magnetUris ? |
| 287 | + JSON.parse(hostData.webTorrent_magnetUris as string) : [] |
| 288 | + } : undefined, |
| 289 | + // Legacy fields for backward compatibility |
| 290 | + externalIp: hostData.externalIp as string, |
| 291 | + port: hostData.port as number, |
| 292 | + lastSeen: lastSeen |
| 293 | + }; |
| 294 | + |
| 295 | + peers.push(capabilities); |
| 296 | + console.log(`✅ [GunRegistry] Added peer: ${capabilities.storeId}`); |
| 297 | + } else { |
| 298 | + console.log(`⏰ [GunRegistry] Host ${hostKey} is stale, skipping`); |
| 299 | + } |
| 300 | + } else { |
| 301 | + console.log(`❌ [GunRegistry] Host ${hostKey} has invalid data structure:`, { |
| 302 | + hasData: !!hostData, |
| 303 | + hasStoreId: !!(hostData && hostData.storeId), |
| 304 | + storeIdType: hostData && hostData.storeId ? typeof hostData.storeId : 'undefined' |
| 305 | + }); |
| 306 | + } |
| 307 | + |
| 308 | + // Check if we've processed all hosts |
| 309 | + if (processedHosts >= totalHosts) { |
| 310 | + clearTimeout(timeout); |
| 311 | + console.log(`📋 [GunRegistry] Final peer list: ${peers.length} peers found`); |
| 312 | + peers.forEach((peer, index) => { |
| 313 | + console.log(` ${index + 1}. ${peer.storeId} - HTTP: ${peer.directHttp?.available || false}, WebTorrent: ${peer.webTorrent?.available || false}`); |
| 314 | + }); |
| 315 | + resolve(peers); |
| 316 | + } |
| 317 | + }); |
| 318 | + } |
| 319 | + } else { |
| 320 | + console.log(`❌ [GunRegistry] No hosts data found in namespace ${this.options.namespace}`); |
| 321 | + clearTimeout(timeout); |
| 322 | + resolve(peers); |
| 323 | + } |
| 324 | + }); |
| 325 | + }); |
| 326 | + } |
| 327 | +} |
0 commit comments