OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore commits / f97ef97

Add relay bridge CLI for P2P ↔ Relay sync
Implements bridge pattern for relay integration: - RelayClient module for HTTP/WS relay communication - WirecoreBridge class for reading Hypercore storage and syncing - Bridge CLI commands: start, push, pull, watch, sync, status Commands: pear run . bridge start - Start daemon (watch + periodic pull) pear run . bridge push - Push all local posts to relay pear run . bridge pull - Pull posts from relay pear run . bridge watch - Watch for new posts and auto-push pear run . bridge sync - One-time sync (push + pull) pear run . bridge status - Show bridge status Benefits: - Clean separation of P2P and relay concerns - Optional service (P2P works without bridge) - Reads from shared Corestore (no duplication) - Configurable relay URL (7rnx.net/relay or pub.wiredove.net) - Tracks sync state (last synced index) Files: - lib/relay-client.js: HTTP/WS relay client (based on wiredove-cli) - bridge/wirecore-bridge.js: Bridge core logic - bridge/cli.js: Bridge CLI entry point - index.js: Add bridge command routing Staged: pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo v4170 Known Issue: - Bridge cannot run while web server holds storage lock - This will be addressed with web UI bridge management Next: Web UI bridge settings panel for managing bridge from browser Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
f97ef97564da1fe7f30e5ef23ca3974a57e9722f
by plankatron <fleeky@gmail.com> · 2026-01-22T15:24:18.000Z

Changed files (4) +852 -0

pear-app/bridge/cli.js +143 -0
……@@ -0,0 +1,143 @@
1#!/usr/bin/env node
2/** @typedef {import('pear-interface')} */ /* global Pear */
3
4/**
5 * Wirecore Bridge CLI
6 *
7 * Commands:
8 * start - Start bridge daemon (watch + periodic pull)
9 * push - Push all local posts to relay
10 * pull - Pull posts from relay
11 * watch - Watch for new posts and auto-push
12 * sync - One-time sync (push + pull)
13 * status - Show bridge status
14 */
15
16import { WirecoreBridge } from './wirecore-bridge.js'
17import process from 'bare-process'
18import goodbye from 'graceful-goodbye'
19
20// Check if running in Pear
21const isPear = typeof Pear !== 'undefined'
22
23// Get configuration from environment or Pear
24const storagePath = isPear && Pear.config.storage
25 ? Pear.config.storage
26 : process.env.PEAR_STORAGE || './wirecore-storage'
27
28const relayUrl = process.env.RELAY_URL || 'https://7rnx.net/relay'
29
30// Parse command line args
31const args = process.argv.slice(2)
32const command = args[0] || 'help'
33
34// Show usage
35function showUsage() {
36 console.log('🌉 Wirecore Bridge - Relay Integration\n')
37 console.log('Usage:')
38 console.log(' pear run . bridge start Start daemon (watch + periodic pull)')
39 console.log(' pear run . bridge push Push all local posts to relay')
40 console.log(' pear run . bridge pull Pull posts from relay')
41 console.log(' pear run . bridge watch Watch for new posts and auto-push')
42 console.log(' pear run . bridge sync One-time sync (push + pull)')
43 console.log(' pear run . bridge status Show bridge status\n')
44 console.log('Environment Variables:')
45 console.log(' RELAY_URL Relay server URL (default: https://7rnx.net/relay)')
46 console.log(' PEAR_STORAGE Storage path (default: auto-detect from Pear)\n')
47 console.log('Examples:')
48 console.log(' # Start bridge daemon')
49 console.log(' pear run . bridge start')
50 console.log('')
51 console.log(' # One-time push of all posts')
52 console.log(' pear run . bridge push')
53 console.log('')
54 console.log(' # Check status')
55 console.log(' pear run . bridge status')
56 console.log('')
57 console.log(' # Use different relay')
58 console.log(' RELAY_URL=https://pub.wiredove.net pear run . bridge start')
59}
60
61async function main() {
62 // Show help
63 if (command === 'help' || command === '--help' || command === '-h') {
64 showUsage()
65 process.exit(0)
66 }
67
68 // Initialize bridge
69 const bridge = new WirecoreBridge(storagePath, relayUrl)
70
71 try {
72 await bridge.init()
73 } catch (err) {
74 console.error('❌ Failed to initialize bridge:', err.message)
75 console.error('\nMake sure you have initialized wirecore first:')
76 console.error(' pear run . cli init')
77 process.exit(1)
78 }
79
80 // Set up cleanup on exit
81 goodbye(async () => {
82 console.log('\n👋 Shutting down bridge...')
83 await bridge.close()
84 })
85
86 // Execute command
87 try {
88 switch (command) {
89 case 'start':
90 // Parse pull interval from args (--interval 300)
91 let pullInterval = 5 * 60 * 1000 // 5 minutes default
92 const intervalIndex = args.indexOf('--interval')
93 if (intervalIndex !== -1 && args[intervalIndex + 1]) {
94 pullInterval = parseInt(args[intervalIndex + 1]) * 1000
95 }
96 await bridge.start(pullInterval)
97 break
98
99 case 'push':
100 await bridge.push()
101 process.exit(0)
102 break
103
104 case 'pull':
105 await bridge.pull()
106 process.exit(0)
107 break
108
109 case 'watch':
110 await bridge.watch()
111 break
112
113 case 'sync':
114 await bridge.sync()
115 process.exit(0)
116 break
117
118 case 'status':
119 await bridge.status()
120 process.exit(0)
121 break
122
123 default:
124 console.error(`❌ Unknown command: ${command}\n`)
125 showUsage()
126 process.exit(1)
127 }
128 } catch (err) {
129 console.error('❌ Error:', err.message)
130 if (process.env.DEBUG) {
131 console.error(err.stack)
132 }
133 process.exit(1)
134 }
135}
136
137main().catch(err => {
138 console.error('Fatal error:', err.message)
139 if (process.env.DEBUG) {
140 console.error(err.stack)
141 }
142 process.exit(1)
143})
pear-app/bridge/wirecore-bridge.js +384 -0
……@@ -0,0 +1,384 @@
1/**
2 * Wirecore Bridge - Local relay for P2P ↔ Relay sync
3 *
4 * Responsibilities:
5 * 1. Read from local Corestore (shared with pear-app)
6 * 2. Watch for new posts in own feed
7 * 3. Push new posts to relay (YAML + signature)
8 * 4. Pull relay posts and cache locally
9 * 5. Maintain sync state
10 */
11
12import Corestore from 'corestore'
13import Hyperbee from 'hyperbee'
14import crypto from 'hypercore-crypto'
15import b4a from 'b4a'
16import { RelayClient } from '../lib/relay-client.js'
17import { ANProto } from '../lib/anproto.js'
18
19export class WirecoreBridge {
20 constructor(storagePath, relayUrl) {
21 this.storagePath = storagePath
22 this.relayUrl = relayUrl
23 this.store = null
24 this.metaBee = null
25 this.syncBee = null // Track sync state
26 this.relayClient = null
27 this.myFeed = null
28 this.anprotoKeypair = null
29 }
30
31 /**
32 * Initialize bridge (read-only access to pear-app storage)
33 */
34 async init() {
35 console.log('🌉 Initializing bridge...')
36 console.log(' Storage:', this.storagePath)
37 console.log(' Relay:', this.relayUrl)
38
39 // Open same Corestore as pear-app
40 this.store = new Corestore(this.storagePath)
41 await this.store.ready()
42
43 // Load metadata (keypair, etc.)
44 this.metaBee = new Hyperbee(this.store.get({ name: 'metadata' }), {
45 keyEncoding: 'utf-8',
46 valueEncoding: 'json'
47 })
48 await this.metaBee.ready()
49
50 // Create sync state tracker
51 this.syncBee = new Hyperbee(this.store.get({ name: 'bridge-sync' }), {
52 keyEncoding: 'utf-8',
53 valueEncoding: 'json'
54 })
55 await this.syncBee.ready()
56
57 // Load ANProto keypair
58 const keypairNode = await this.metaBee.get('anproto-keypair')
59 if (!keypairNode) {
60 throw new Error('No identity found. Run: pear run . cli init')
61 }
62
63 const { publicKey, secretKey } = ANProto.loadKeypair(keypairNode.value)
64 this.anprotoKeypair = { publicKey, secretKey }
65
66 // Open own feed
67 this.myFeed = this.store.get({
68 publicKey: this.anprotoKeypair.publicKey,
69 secretKey: this.anprotoKeypair.secretKey
70 })
71 await this.myFeed.ready()
72
73 // Initialize relay client
74 this.relayClient = new RelayClient(this.relayUrl)
75
76 console.log('✅ Bridge initialized')
77 console.log(' Pubkey:', b4a.toString(this.anprotoKeypair.publicKey, 'base64'))
78 console.log(' Feed length:', this.myFeed.length)
79 }
80
81 /**
82 * Get last synced index for own feed
83 */
84 async getLastSyncedIndex() {
85 const node = await this.syncBee.get('last-synced-index')
86 return node ? node.value : 0
87 }
88
89 /**
90 * Set last synced index
91 */
92 async setLastSyncedIndex(index) {
93 await this.syncBee.put('last-synced-index', index)
94 }
95
96 /**
97 * Get sync statistics
98 */
99 async getStats() {
100 const node = await this.syncBee.get('stats')
101 return node ? node.value : {
102 totalPushed: 0,
103 totalPulled: 0,
104 lastSync: null,
105 lastError: null
106 }
107 }
108
109 /**
110 * Update sync statistics
111 */
112 async updateStats(updates) {
113 const stats = await this.getStats()
114 const newStats = { ...stats, ...updates, lastSync: Date.now() }
115 await this.syncBee.put('stats', newStats)
116 return newStats
117 }
118
119 /**
120 * Push all unsent posts to relay
121 */
122 async push() {
123 const lastSynced = await this.getLastSyncedIndex()
124 const currentLength = this.myFeed.length
125
126 if (lastSynced >= currentLength) {
127 console.log('✓ Already up to date (no new posts)')
128 return { pushed: 0, errors: 0 }
129 }
130
131 console.log(`📤 Pushing ${currentLength - lastSynced} posts to relay...`)
132
133 const pubkey = b4a.toString(this.anprotoKeypair.publicKey, 'base64')
134 let pushed = 0
135 let errors = 0
136
137 for (let i = lastSynced; i < currentLength; i++) {
138 try {
139 const block = await this.myFeed.get(i)
140 const entry = JSON.parse(block.toString())
141
142 // Extract YAML and create signature
143 const yaml = entry.text
144 const timestamp = entry.timestamp || Date.now().toString()
145
146 // Hash YAML content
147 const contentHash = crypto.hash(Buffer.from(yaml, 'utf-8')).toString('base64url')
148
149 // Create signature (timestamp + contentHash)
150 const signaturePayload = timestamp + contentHash
151 const signature = ANProto.sign(signaturePayload, this.anprotoKeypair.secretKey)
152
153 // Push to relay
154 await this.relayClient.push(pubkey, yaml, signature, null)
155 console.log(` ✓ Pushed post ${i}`)
156
157 pushed++
158
159 // Update sync state
160 await this.setLastSyncedIndex(i + 1)
161 } catch (err) {
162 console.error(` ✗ Failed to push post ${i}:`, err.message)
163 errors++
164 // Continue with next post
165 }
166 }
167
168 console.log(`✅ Push complete: ${pushed} pushed, ${errors} errors`)
169
170 // Update stats
171 const stats = await this.getStats()
172 await this.updateStats({
173 totalPushed: stats.totalPushed + pushed
174 })
175
176 return { pushed, errors }
177 }
178
179 /**
180 * Pull posts from relay and cache locally
181 */
182 async pull() {
183 console.log('📥 Pulling from relay...')
184
185 try {
186 const posts = await this.relayClient.pull()
187 const count = Array.isArray(posts) ? posts.length : 0
188 console.log(` Received ${count} posts`)
189
190 // TODO: Store in local Hyperbee cache for timeline integration
191 // For now, just log the posts
192 if (Array.isArray(posts)) {
193 const recent = posts.slice(0, 5)
194 console.log(` Showing ${recent.length} most recent:`)
195 recent.forEach(post => {
196 const author = post.author ? post.author.substring(0, 16) + '...' : 'unknown'
197 const preview = post.text ? post.text.substring(0, 50) : ''
198 console.log(` • ${author}: ${preview}`)
199 })
200 }
201
202 console.log('✅ Pull complete')
203
204 // Update stats
205 const stats = await this.getStats()
206 await this.updateStats({
207 totalPulled: stats.totalPulled + count
208 })
209
210 return { pulled: count }
211 } catch (err) {
212 console.error('❌ Pull failed:', err.message)
213 await this.updateStats({ lastError: err.message })
214 throw err
215 }
216 }
217
218 /**
219 * Watch for new posts and auto-push
220 */
221 async watch() {
222 console.log('👁️ Watching for new posts...')
223 console.log(' Press Ctrl+C to stop\n')
224
225 // Set up feed listener
226 this.myFeed.on('append', async () => {
227 console.log('🔔 New post detected!')
228 try {
229 await this.push()
230 } catch (err) {
231 console.error('Failed to push:', err.message)
232 }
233 })
234
235 // Keep process alive
236 await new Promise(() => {})
237 }
238
239 /**
240 * Daemon mode: watch + periodic pull
241 */
242 async start(pullInterval = 5 * 60 * 1000) {
243 console.log('🚀 Starting bridge daemon...')
244 console.log(` Pull interval: ${pullInterval / 1000}s\n`)
245
246 // Initial sync
247 try {
248 await this.push()
249 } catch (err) {
250 console.error('Initial push failed:', err.message)
251 }
252
253 try {
254 await this.pull()
255 } catch (err) {
256 console.error('Initial pull failed:', err.message)
257 }
258
259 // Watch for new posts
260 this.myFeed.on('append', async () => {
261 console.log('🔔 New post detected!')
262 try {
263 await this.push()
264 } catch (err) {
265 console.error('Failed to push:', err.message)
266 }
267 })
268
269 // Periodic pull from relay
270 setInterval(async () => {
271 console.log('⏰ Periodic pull from relay...')
272 try {
273 await this.pull()
274 } catch (err) {
275 console.error('Periodic pull failed:', err.message)
276 }
277 }, pullInterval)
278
279 console.log('✅ Bridge daemon running\n')
280
281 // Keep alive
282 await new Promise(() => {})
283 }
284
285 /**
286 * One-time sync (push + pull)
287 */
288 async sync() {
289 console.log('🔄 Syncing with relay...\n')
290
291 let pushResult = { pushed: 0, errors: 0 }
292 let pullResult = { pulled: 0 }
293
294 try {
295 pushResult = await this.push()
296 } catch (err) {
297 console.error('Push failed:', err.message)
298 }
299
300 try {
301 pullResult = await this.pull()
302 } catch (err) {
303 console.error('Pull failed:', err.message)
304 }
305
306 console.log('\n✅ Sync complete')
307 console.log(` Pushed: ${pushResult.pushed}`)
308 console.log(` Pulled: ${pullResult.pulled}`)
309
310 return { ...pushResult, ...pullResult }
311 }
312
313 /**
314 * Status check
315 */
316 async status() {
317 const lastSynced = await this.getLastSyncedIndex()
318 const currentLength = this.myFeed.length
319 const pending = currentLength - lastSynced
320 const stats = await this.getStats()
321
322 // Check relay health
323 const health = await this.relayClient.health()
324
325 const status = {
326 relay: {
327 url: this.relayUrl,
328 online: health.online,
329 error: health.error || null
330 },
331 storage: {
332 path: this.storagePath,
333 feedLength: currentLength
334 },
335 sync: {
336 lastSyncedIndex: lastSynced,
337 pendingPosts: pending,
338 lastSync: stats.lastSync,
339 lastError: stats.lastError
340 },
341 stats: {
342 totalPushed: stats.totalPushed,
343 totalPulled: stats.totalPulled
344 }
345 }
346
347 console.log('\nBridge Status:')
348 console.log('━━━━━━━━━━━━━━')
349 console.log('Relay:')
350 console.log(` URL: ${status.relay.url}`)
351 console.log(` Status: ${status.relay.online ? '🟢 Online' : '🔴 Offline'}`)
352 if (status.relay.error) {
353 console.log(` Error: ${status.relay.error}`)
354 }
355 console.log('\nStorage:')
356 console.log(` Path: ${status.storage.path}`)
357 console.log(` Feed: ${status.storage.feedLength} posts`)
358 console.log('\nSync:')
359 console.log(` Last index: ${status.sync.lastSyncedIndex}`)
360 console.log(` Pending: ${status.sync.pendingPosts} posts`)
361 if (status.sync.lastSync) {
362 console.log(` Last sync: ${new Date(status.sync.lastSync).toLocaleString()}`)
363 }
364 if (status.sync.lastError) {
365 console.log(` Last error: ${status.sync.lastError}`)
366 }
367 console.log('\nStatistics:')
368 console.log(` Pushed: ${status.stats.totalPushed}`)
369 console.log(` Pulled: ${status.stats.totalPulled}`)
370
371 return status
372 }
373
374 /**
375 * Cleanup resources
376 */
377 async close() {
378 console.log('Closing bridge...')
379 if (this.store) {
380 await this.store.close()
381 }
382 console.log('✅ Bridge closed')
383 }
384}
pear-app/index.js +64 -0
……@@ -1479,6 +1479,7 @@ function showUsage() {
14791479 console.log('Usage:')
14801480 console.log(' pear run . cli Start in CLI mode (interactive commands)')
14811481 console.log(' pear run . web [port] Start web server (default port: 3003)')
1482 console.log(' pear run . bridge <command> Relay bridge commands')
14821483 console.log('')
14831484 console.log('CLI Commands:')
14841485 console.log(' pear run . cli init Initialize new identity')
……@@ -1535,6 +1536,69 @@ async function main() {
15351536 await startWebServer(customPort)
15361537 return // Keep server running - cleanup handled by graceful-goodbye
15371538
1539 case 'bridge':
1540 // Bridge mode - run bridge commands directly
1541 const { WirecoreBridge } = await import('./bridge/wirecore-bridge.js')
1542 const RELAY_URL = process.env.RELAY_URL || 'https://7rnx.net/relay'
1543 const bridge = new WirecoreBridge(STORAGE_PATH, RELAY_URL)
1544
1545 await bridge.init()
1546
1547 const bridgeCommand = params[0]
1548
1549 switch (bridgeCommand) {
1550 case 'start':
1551 // Parse pull interval
1552 let pullInterval = 5 * 60 * 1000
1553 const intervalIndex = params.indexOf('--interval')
1554 if (intervalIndex !== -1 && params[intervalIndex + 1]) {
1555 pullInterval = parseInt(params[intervalIndex + 1]) * 1000
1556 }
1557 await bridge.start(pullInterval)
1558 return // Keep running
1559
1560 case 'push':
1561 await bridge.push()
1562 break
1563
1564 case 'pull':
1565 await bridge.pull()
1566 break
1567
1568 case 'watch':
1569 await bridge.watch()
1570 return // Keep running
1571
1572 case 'sync':
1573 await bridge.sync()
1574 break
1575
1576 case 'status':
1577 await bridge.status()
1578 break
1579
1580 case 'help':
1581 case '--help':
1582 case '-h':
1583 case undefined:
1584 console.log('🌉 Wirecore Bridge - Relay Integration\n')
1585 console.log('Usage:')
1586 console.log(' pear run . bridge start Start daemon (watch + periodic pull)')
1587 console.log(' pear run . bridge push Push all local posts to relay')
1588 console.log(' pear run . bridge pull Pull posts from relay')
1589 console.log(' pear run . bridge watch Watch for new posts and auto-push')
1590 console.log(' pear run . bridge sync One-time sync (push + pull)')
1591 console.log(' pear run . bridge status Show bridge status')
1592 break
1593
1594 default:
1595 console.error(`❌ Unknown bridge command: ${bridgeCommand}`)
1596 console.log('Run: pear run . bridge help')
1597 }
1598
1599 await bridge.close()
1600 break
1601
15381602 case 'cli':
15391603 // CLI mode - execute command
15401604 if (params.length === 0) {
pear-app/lib/relay-client.js +261 -0
……@@ -0,0 +1,261 @@
1/**
2 * RelayClient - Simple HTTP + short-lived WS relay interaction
3 *
4 * Based on proven wiredove-cli patterns:
5 * - Push: Short-lived WebSocket (connect, send, close)
6 * - Pull: HTTP GET requests
7 * - No persistent state
8 * - Configurable relay URL
9 */
10
11import https from 'bare-https'
12import http from 'bare-http1'
13
14export class RelayClient {
15 constructor(relayUrl = 'https://7rnx.net/relay') {
16 this.relayUrl = relayUrl
17 this.wsUrl = relayUrl.replace('https://', 'wss://').replace('http://', 'ws://')
18 }
19
20 /**
21 * Push post to relay (short-lived WebSocket)
22 *
23 * @param {string} pubkey - Author public key (base64, 44 chars)
24 * @param {string} yaml - YAML message content
25 * @param {string} signature - ANProto signature (base64, 132 bytes)
26 * @param {string} [imageData] - Optional image data
27 * @returns {Promise<void>}
28 */
29 async push(pubkey, yaml, signature, imageData = null) {
30 // Dynamically import WebSocket (bare-ws)
31 const bareWs = await import('bare-ws')
32 const { WebSocket } = bareWs
33
34 return new Promise((resolve, reject) => {
35 console.log(`📤 Pushing to relay: ${this.wsUrl}`)
36
37 const ws = new WebSocket(this.wsUrl)
38 const timeout = setTimeout(() => {
39 ws.close()
40 reject(new Error('Relay push timeout (10s)'))
41 }, 10000)
42
43 ws.on('open', () => {
44 clearTimeout(timeout)
45 console.log(' ✓ Connected to relay')
46
47 try {
48 // Send in order: pubkey, content, image (optional), signature
49 ws.send(pubkey)
50 console.log(' → Sent pubkey')
51
52 ws.send(yaml)
53 console.log(' → Sent content')
54
55 if (imageData) {
56 ws.send(imageData)
57 console.log(' → Sent image')
58 }
59
60 ws.send(signature)
61 console.log(' → Sent signature')
62
63 // Close after 2 seconds (relay processes messages)
64 setTimeout(() => {
65 ws.close()
66 console.log('✅ Post pushed to relay')
67 resolve()
68 }, 2000)
69 } catch (err) {
70 clearTimeout(timeout)
71 ws.close()
72 reject(err)
73 }
74 })
75
76 ws.on('error', (err) => {
77 clearTimeout(timeout)
78 console.error('❌ Relay push error:', err.message)
79 reject(err)
80 })
81
82 ws.on('close', () => {
83 clearTimeout(timeout)
84 })
85 })
86 }
87
88 /**
89 * Pull posts from relay (HTTP GET)
90 *
91 * @param {Object} options - Query options
92 * @param {number} [options.limit] - Max posts to fetch
93 * @param {string} [options.author] - Filter by author pubkey
94 * @returns {Promise<Array>} Array of posts
95 */
96 async pull(options = {}) {
97 const endpoint = options.author
98 ? `/directory/${encodeURIComponent(options.author)}`
99 : '/all'
100
101 const url = `${this.relayUrl}${endpoint}`
102 console.log(`📥 Pulling from relay: ${url}`)
103
104 return new Promise((resolve, reject) => {
105 const timeout = setTimeout(() => {
106 reject(new Error('Relay pull timeout (30s)'))
107 }, 30000)
108
109 const protocol = this.relayUrl.startsWith('https://') ? https : http
110 const urlObj = new URL(url)
111
112 const options = {
113 hostname: urlObj.hostname,
114 port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
115 path: urlObj.pathname + urlObj.search,
116 method: 'GET'
117 }
118
119 const req = protocol.request(options, (res) => {
120 let data = ''
121
122 res.on('data', chunk => {
123 data += chunk.toString()
124 })
125
126 res.on('end', () => {
127 clearTimeout(timeout)
128 try {
129 const posts = JSON.parse(data)
130 const count = Array.isArray(posts) ? posts.length : (posts.total || 0)
131 console.log(`✅ Pulled ${count} posts from relay`)
132 resolve(posts)
133 } catch (err) {
134 reject(new Error(`Invalid JSON response: ${err.message}`))
135 }
136 })
137
138 res.on('error', (err) => {
139 clearTimeout(timeout)
140 reject(err)
141 })
142 })
143
144 req.on('error', (err) => {
145 clearTimeout(timeout)
146 reject(err)
147 })
148
149 req.end()
150 })
151 }
152
153 /**
154 * Pull user directory from relay
155 *
156 * @param {string} [searchQuery] - Optional search query
157 * @returns {Promise<Object>} Directory response
158 */
159 async pullDirectory(searchQuery = null) {
160 const endpoint = searchQuery
161 ? `/directory/search?q=${encodeURIComponent(searchQuery)}`
162 : '/directory'
163
164 const url = `${this.relayUrl}${endpoint}`
165 console.log(`📥 Pulling directory: ${url}`)
166
167 return new Promise((resolve, reject) => {
168 const timeout = setTimeout(() => {
169 reject(new Error('Directory timeout (30s)'))
170 }, 30000)
171
172 const protocol = this.relayUrl.startsWith('https://') ? https : http
173 const urlObj = new URL(url)
174
175 const options = {
176 hostname: urlObj.hostname,
177 port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
178 path: urlObj.pathname + urlObj.search,
179 method: 'GET'
180 }
181
182 const req = protocol.request(options, (res) => {
183 let data = ''
184 res.on('data', chunk => data += chunk.toString())
185 res.on('end', () => {
186 clearTimeout(timeout)
187 try {
188 resolve(JSON.parse(data))
189 } catch (err) {
190 reject(new Error(`Invalid JSON: ${err.message}`))
191 }
192 })
193 res.on('error', (err) => {
194 clearTimeout(timeout)
195 reject(err)
196 })
197 })
198
199 req.on('error', (err) => {
200 clearTimeout(timeout)
201 reject(err)
202 })
203
204 req.end()
205 })
206 }
207
208 /**
209 * Check relay health
210 *
211 * @returns {Promise<Object>} Health status
212 */
213 async health() {
214 try {
215 const url = `${this.relayUrl}/stats`
216 console.log(`🏥 Checking relay health: ${url}`)
217
218 const stats = await new Promise((resolve, reject) => {
219 const timeout = setTimeout(() => reject(new Error('Timeout')), 5000)
220
221 const protocol = this.relayUrl.startsWith('https://') ? https : http
222 const urlObj = new URL(url)
223
224 const options = {
225 hostname: urlObj.hostname,
226 port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
227 path: urlObj.pathname,
228 method: 'GET'
229 }
230
231 const req = protocol.request(options, (res) => {
232 let data = ''
233 res.on('data', chunk => data += chunk.toString())
234 res.on('end', () => {
235 clearTimeout(timeout)
236 try {
237 resolve(JSON.parse(data))
238 } catch {
239 reject(new Error('Invalid response'))
240 }
241 })
242 })
243
244 req.on('error', reject)
245 req.end()
246 })
247
248 return {
249 online: true,
250 url: this.relayUrl,
251 stats
252 }
253 } catch (err) {
254 return {
255 online: false,
256 url: this.relayUrl,
257 error: err.message
258 }
259 }
260 }
261}