OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / lib / core / wirecore.js

671 lines · javascript ref:
/**
 * Wirecore - P2P Social Network
 *
 * Bridges Wiredove (WebRTC) and Hypercore (Hyperswarm)
 * using ANProto signatures for message authentication
 *
 * ANProto-compliant format:
 * - Hypercore blocks contain ONLY signatures
 * - Content stored separately by hash
 * - Messages use YAML format with name/image/previous
 */

import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import b4a from 'b4a'
import { ANProto } from './anproto.js'
import { YAMLHelper } from './yaml-helper.js'
import { ContentStore } from './content-store.js'
import { ContentSyncManager } from './content-sync.js'
import { HyperswarmContentTransport } from './hyperswarm-content-transport.js'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { EventEmitter } from 'events'

export class Wirecore extends EventEmitter {
  constructor(storagePath = './wirecore-storage') {
    super()

    this.storagePath = storagePath
    this.store = null
    this.swarm = null
    this.myFeed = null
    this.keypair = null
    this.contentStore = null
    this.contentSync = null
    this.contentTransport = null
    this.follows = new Map() // publicKey -> { feed, feedKey, name }
    this.ready = false

    // Profile fields (for YAML metadata)
    this.name = 'Anonymous'
    this.imageHash = null
  }

  /**
   * Initialize Wirecore
   * @param {Object} options - { keypair?: string }
   */
  async init(options = {}) {
    // Create storage directory
    await mkdir(this.storagePath, { recursive: true })

    // Load or create keypair
    if (options.keypair) {
      this.keypair = ANProto.loadKeypair(options.keypair)
    } else {
      this.keypair = await this._loadOrCreateKeypair()
    }

    // Initialize content store
    this.contentStore = new ContentStore(this.storagePath)
    await this.contentStore.init()

    // Load profile
    await this._loadProfile()

    // Initialize Corestore
    this.store = new Corestore(join(this.storagePath, 'feeds'))
    await this.store.ready()

    // Get personal feed (writable)
    this.myFeed = this.store.get({ name: 'my-feed' })
    await this.myFeed.ready()

    // Initialize Hyperswarm
    this.swarm = new Hyperswarm()

    // Initialize content sync layer
    this.contentSync = new ContentSyncManager(this.contentStore, {
      timeout: 30000,           // 30s timeout per request
      maxRetries: 2,            // Retry up to 2 times
      maxConcurrent: 10         // Max 10 concurrent requests
    })

    this.contentTransport = new HyperswarmContentTransport(this.swarm, this.contentSync)

    this.swarm.on('connection', (conn) => {
      this.store.replicate(conn)
      this.contentTransport.attachToConnection(conn)  // Add content sync
    })

    // Join own topic
    const topic = this.myFeed.discoveryKey
    this.swarm.join(topic, { server: true, client: true })
    await this.swarm.flush()

    // Load follow graph from feed
    await this._loadFollows()

    this.ready = true
    this.emit('ready')

    console.log('Wirecore initialized')
    console.log('Public key:', this.getPublicKey())
    console.log('Feed key:', b4a.toString(this.myFeed.key, 'hex'))
    console.log('Following:', this.follows.size, 'users')
  }

  /**
   * Get user's public key (ANProto format)
   */
  getPublicKey() {
    return b4a.toString(this.keypair.publicKey, 'base64')
  }

  /**
   * Get Hypercore feed key (base64)
   */
  getFeedKey() {
    return b4a.toString(this.myFeed.key, 'base64')
  }

  /**
   * Get user's full keypair (for backup)
   */
  getKeypair() {
    return b4a.toString(this.keypair.publicKey, 'base64') +
           b4a.toString(this.keypair.secretKey, 'base64')
  }

  /**
   * Post a message (ANProto-compliant)
   * @param {Object|String} content - { type, text } or just text
   */
  async post(content) {
    // Handle both object and string input
    const text = typeof content === 'string' ? content : (content.text || JSON.stringify(content))

    // 1. Get previous message hash
    const previous = await this.getLatestHash()

    // 2. Create YAML document with metadata
    const yaml = YAMLHelper.create({
      name: this.name,
      image: this.imageHash,
      previous
    }, text)

    // 3. Hash the content
    const contentHash = await ANProto.hashData(yaml)

    // 4. Store content by hash
    await this.contentStore.put(contentHash, yaml)

    // 5. Sign the hash (ANProto format)
    const signature = await ANProto.signHash(contentHash, this.keypair)

    // 6. Append ONLY the signature to Hypercore
    await this.myFeed.append(Buffer.from(signature))

    // 7. Parse for return value
    const opened = await ANProto.verifySignature(signature)
    const timestamp = Number(opened.substring(0, 13))

    const message = {
      signature,
      hash: contentHash,
      timestamp,
      sequence: this.myFeed.length - 1,
      author: this.getPublicKey(),
      content: { type: content.type || 'post', text }
    }

    this.emit('post', message)

    return message
  }

  /**
   * Update profile
   * @param {Object} profile - { name, image }
   */
  async updateProfile(profile) {
    if (profile.name) {
      this.name = profile.name
    }

    if (profile.image) {
      this.imageHash = profile.image
    }

    await this._saveProfile()

    // Post a profile update message
    return this.post({ type: 'profile', text: `Updated profile: ${this.name}` })
  }

  /**
   * Follow a user
   * @param {string} publicKey - ANProto public key (base64)
   * @param {string} feedKey - Hypercore feed key (base64)
   * @param {string} name - Display name (optional)
   */
  async follow(publicKey, feedKey, name = 'Anonymous') {
    if (this.follows.has(publicKey)) {
      console.log('Already following', name)
      return
    }

    // Post follow message to feed
    await this.post({
      type: 'follow',
      user: publicKey,
      feedKey: feedKey,
      name: name
    })

    // Get their feed using the Hypercore feed key
    const feedKeyBuffer = b4a.from(feedKey, 'base64')
    const feed = this.store.get(feedKeyBuffer)
    await feed.ready()

    this.follows.set(publicKey, { feed, feedKey, name })

    // Join their Hyperswarm topic for P2P replication
    const topic = feed.discoveryKey
    this.swarm.join(topic, { server: false, client: true })

    // Bulk download recent content after connection
    setImmediate(async () => {
      try {
        // Wait for feed to sync signatures
        await feed.update()

        // Find missing content for last 50 posts
        const recentCount = Math.min(feed.length, 50)
        const missingHashes = []

        for (let i = feed.length - recentCount; i < feed.length; i++) {
          const block = await feed.get(i)
          const signature = block.toString()
          const opened = await ANProto.verifySignature(signature)
          const hash = opened.substring(13)

          if (!this.contentStore.has(hash)) {
            missingHashes.push(hash)
          }
        }

        if (missingHashes.length > 0) {
          console.log(`Requesting ${missingHashes.length} content blocks for ${name}`)

          // Batch request (don't block on completion)
          Promise.all(missingHashes.map(hash =>
            this.contentSync.requestContent(hash).catch(err =>
              console.error('Content fetch failed:', hash.substring(0, 8), err.message)
            )
          ))
        }
      } catch (err) {
        console.error('Bulk download error:', err.message)
      }
    })

    this.emit('follow', { publicKey, name })

    console.log('Following', name, '(', publicKey.substring(0, 8), '...)')
  }

  /**
   * Unfollow a user
   * @param {string} publicKey - ANProto public key
   */
  async unfollow(publicKey) {
    if (!this.follows.has(publicKey)) {
      console.log('Not following this user')
      return
    }

    await this.post({
      type: 'unfollow',
      user: publicKey
    })

    const { feed } = this.follows.get(publicKey)
    this.follows.delete(publicKey)

    // Leave their topic
    const topic = feed.discoveryKey
    await this.swarm.leave(topic)

    this.emit('unfollow', { publicKey })

    console.log('Unfollowed', publicKey.substring(0, 8), '...')
  }

  /**
   * Like a post
   * @param {string} author - Post author's public key
   * @param {number} sequence - Post sequence number
   */
  async like(author, sequence) {
    return this.post({
      type: 'like',
      target: {
        author,
        sequence
      }
    })
  }

  /**
   * Boost (repost) a post
   * @param {string} author - Post author's public key
   * @param {number} sequence - Post sequence number
   */
  async boost(author, sequence) {
    return this.post({
      type: 'boost',
      target: {
        author,
        sequence
      }
    })
  }

  /**
   * Reply to a post
   * @param {string} author - Post author's public key
   * @param {number} sequence - Post sequence number
   * @param {string} text - Reply text
   */
  async reply(author, sequence, text) {
    return this.post({
      type: 'reply',
      text,
      target: {
        author,
        sequence
      }
    })
  }

  /**
   * Get timeline (aggregated feed from follows)
   * @param {Object} options - { limit: 50, types: ['post', 'boost'] }
   * @returns {Array} Array of posts sorted by timestamp
   */
  async getTimeline(options = {}) {
    const limit = options.limit || 50
    const types = options.types || ['post', 'reply', 'boost']

    const posts = []

    // Get posts from own feed
    const myPosts = await this._getFeedPosts(this.myFeed, this.getPublicKey())
    posts.push(...myPosts.filter(p => types.includes(p.content.type)))

    // Get posts from followed users
    for (const [publicKey, { feed, name }] of this.follows) {
      const feedPosts = await this._getFeedPosts(feed, publicKey)
      posts.push(...feedPosts.filter(p => types.includes(p.content.type)))
    }

    // Sort by timestamp (newest first)
    posts.sort((a, b) => b.timestamp - a.timestamp)

    // Return limited results
    return posts.slice(0, limit)
  }

  /**
   * Get list of users being followed
   * @returns {Array} Array of { publicKey, name, feedKey }
   */
  getFollowing() {
    const following = []
    for (const [publicKey, { name, feedKey }] of this.follows) {
      following.push({ publicKey, name, feedKey })
    }
    return following
  }

  /**
   * Get user's profile
   * @param {string} publicKey - User's public key
   * @returns {Object} Profile object
   */
  async getProfile(publicKey) {
    let feed, name

    if (publicKey === this.getPublicKey()) {
      feed = this.myFeed
      name = 'You'
    } else if (this.follows.has(publicKey)) {
      ({ feed, name } = this.follows.get(publicKey))
    } else {
      // Try to load unknown user's feed
      const feedKey = b4a.from(publicKey, 'base64')
      feed = this.store.get(feedKey)
      await feed.ready()
      name = 'Unknown'
    }

    // Find most recent profile message
    let profile = { name, bio: '', avatar: '' }

    for (let i = feed.length - 1; i >= 0; i--) {
      try {
        const block = await feed.get(i)
        const signature = block.toString()

        // Verify and extract hash
        const opened = await ANProto.verifySignature(signature)
        const contentHash = opened.substring(13)

        // Get content from store
        const yaml = this.contentStore.get(contentHash)
        if (!yaml) continue

        const parsed = YAMLHelper.parse(yaml)

        // Try to parse content as JSON to check type
        try {
          const content = JSON.parse(parsed.content)
          if (content.type === 'profile') {
            profile = {
              name: content.name || parsed.metadata.name || name,
              bio: content.bio || '',
              avatar: content.avatar || ''
            }
            break
          }
        } catch {
          // Not JSON or not a profile message
        }
      } catch (err) {
        console.error('Error reading profile at', i, err.message)
      }
    }

    return {
      publicKey,
      ...profile,
      posts: feed.length
    }
  }

  /**
   * Close Fedicore
   */
  async close() {
    if (this.swarm) {
      await this.swarm.destroy()
    }
    if (this.store) {
      await this.store.close()
    }
    this.emit('close')
  }

  // ===== PRIVATE METHODS =====

  async _loadOrCreateKeypair() {
    const keypairPath = join(this.storagePath, 'keypair.json')

    try {
      const data = await readFile(keypairPath, 'utf8')
      const { keypair } = JSON.parse(data)
      console.log('Loaded existing keypair')
      return ANProto.loadKeypair(keypair)
    } catch (err) {
      // Generate new keypair
      const generated = ANProto.gen()
      await writeFile(keypairPath, JSON.stringify({
        keypair: generated.keypair,
        publicKey: b4a.toString(generated.publicKey, 'base64')
      }, null, 2))
      console.log('Generated new keypair')
      return generated
    }
  }

  async _loadFollows() {
    const follows = new Map()
    const unfollows = new Set()

    // Scan feed for follow/unfollow messages
    for (let i = 0; i < this.myFeed.length; i++) {
      try {
        const block = await this.myFeed.get(i)
        const signature = block.toString()

        // Verify and extract hash
        const opened = await ANProto.verifySignature(signature)
        const contentHash = opened.substring(13)

        // Get content from store
        const yaml = this.contentStore.get(contentHash)
        if (!yaml) continue

        const parsed = YAMLHelper.parse(yaml)

        // Try to parse content as JSON
        try {
          const content = JSON.parse(parsed.content)

          if (content.type === 'follow') {
            const { user, feedKey, name } = content
            follows.set(user, { feedKey, name })
            unfollows.delete(user)
          } else if (content.type === 'unfollow') {
            const { user } = content
            follows.delete(user)
            unfollows.add(user)
          }
        } catch {
          // Not JSON, skip
        }
      } catch (err) {
        console.error('Error loading follow at', i, err.message)
      }
    }

    // Load each followed user's feed
    for (const [publicKey, data] of follows) {
      try {
        const { feedKey, name } = data

        // Load their Hypercore feed
        const feedKeyBuffer = b4a.from(feedKey, 'base64')
        const feed = this.store.get(feedKeyBuffer)
        await feed.ready()

        this.follows.set(publicKey, { feed, feedKey, name })

        // Join their Hyperswarm topic for P2P replication
        const topic = feed.discoveryKey
        this.swarm.join(topic, { server: false, client: true })

        console.log(`✓ Restored follow: ${name} (${publicKey.substring(0, 8)}...)`)
      } catch (err) {
        console.error('Failed to load feed for', name, err.message)
      }
    }
  }

  async _getFeedPosts(feed, author) {
    const posts = []

    for (let i = 0; i < feed.length; i++) {
      try {
        // 1. Get signature from Hypercore block
        const block = await feed.get(i)
        const signature = block.toString()

        // 2. Verify and extract timestamp + hash
        const opened = await ANProto.verifySignature(signature)
        const timestamp = Number(opened.substring(0, 13))
        const contentHash = opened.substring(13)

        // 3. Retrieve content from content store
        let yaml = this.contentStore.get(contentHash)
        if (!yaml) {
          // Try to fetch from network
          try {
            console.log(`Content missing for ${contentHash.substring(0, 8)}..., requesting from network`)
            yaml = await this.contentSync.requestContent(contentHash, {
              timeout: 10000,  // 10s for timeline loading
              skipIfNoPeers: true
            })

            if (yaml) {
              // Content received and already stored by ContentSyncManager
              console.log(`✓ Fetched content for ${contentHash.substring(0, 8)}...`)
            } else {
              console.error(`Content not found for hash: ${contentHash}`)
              continue
            }
          } catch (err) {
            console.error(`Failed to fetch content for ${contentHash}:`, err.message)
            continue
          }
        }

        // 4. Parse YAML to extract metadata and content
        const parsed = YAMLHelper.parse(yaml)

        // 5. Determine message type from content
        let messageType = 'post'
        let messageContent = parsed.content

        // Try to parse as JSON to detect special types (follow, like, boost, etc.)
        try {
          const jsonContent = JSON.parse(parsed.content)
          if (jsonContent.type) {
            messageType = jsonContent.type
            messageContent = jsonContent
          }
        } catch {
          // Not JSON, treat as plain text post
        }

        posts.push({
          signature,
          timestamp,
          hash: contentHash,
          author,
          sequence: i,
          name: parsed.metadata.name,
          image: parsed.metadata.image,
          previous: parsed.metadata.previous,
          content: {
            type: messageType,
            ...(typeof messageContent === 'string' ? { text: messageContent } : messageContent)
          }
        })
      } catch (err) {
        console.error('Failed to parse message', i, err.message)
      }
    }

    return posts
  }

  /**
   * Get hash of latest message (for 'previous' chain)
   */
  async getLatestHash() {
    if (this.myFeed.length === 0) {
      return null
    }

    const lastBlock = await this.myFeed.get(this.myFeed.length - 1)
    const signature = lastBlock.toString()
    const opened = await ANProto.verifySignature(signature)

    // Extract hash from opened signature (timestamp + hash)
    return opened.substring(13)
  }

  /**
   * Load profile from disk
   */
  async _loadProfile() {
    const profilePath = join(this.storagePath, 'profile.json')
    try {
      const data = await readFile(profilePath, 'utf8')
      const profile = JSON.parse(data)
      this.name = profile.name || 'Anonymous'
      this.imageHash = profile.image || null
      console.log('Loaded profile:', this.name)
    } catch (err) {
      // No profile yet, use defaults
      this.name = 'Anonymous'
      this.imageHash = null
    }
  }

  /**
   * Save profile to disk
   */
  async _saveProfile() {
    const profilePath = join(this.storagePath, 'profile.json')
    await writeFile(profilePath, JSON.stringify({
      name: this.name,
      image: this.imageHash
    }, null, 2))
  }
}