/**
* 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))
}
}