WeaveDocs

Social Feed

Build a small public feed app with WOVEN, weave-swarm, and Lens.

What you'll build

A node that publishes signed posts to a WOVEN topic, subscribes to incoming posts, and indexes every post into a Lens for fast lookup by author or hashtag.

Time: 20 minutes.

Primitives used: WOVEN (signed event log), weave-swarm (topic subscription and fan-out), Lens (B-tree index).

Prerequisites

  • Rust 1.78+.
  • Two or more terminals to play the role of separate nodes.

Build the feed node

  1. 1
    Scaffold the project
    cargo new --bin social-feed
    cd social-feed
  2. 2
    Declare dependencies
    [package]
    name = "social-feed"
    version = "0.1.0"
    edition = "2021"
    
    [dependencies]
    weave-sdk = "1.1.0"
    tokio     = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util"] }
    serde     = { version = "1", features = ["derive"] }
    serde_json = "1"
    anyhow    = "1"
    blake3    = "1"
  3. 3
    Write the feed node
    use anyhow::Result;
    use serde::{Deserialize, Serialize};
    use std::env;
    use tokio::io::{AsyncBufReadExt, BufReader};
    use weave_sdk::prelude::*;
    
    #[derive(Serialize, Deserialize, Clone)]
    struct Post {
        id:      String,       // blake3 of (author, body, ts)
        author:  String,       // DID
        body:    String,
        ts_ms:   u64,
        tags:    Vec<String>,
    }
    
    #[tokio::main]
    async fn main() -> Result<()> {
        let id = env::args().nth(1).unwrap_or_else(|| "node-a".to_string());
        let node = WeaveNode::builder()
            .namespace("feed-demo")
            .identifier(&id)
            .storage_dir(format!("/tmp/feed-{id}"))
            .build()
            .await?;
    
        node.start_network().await?;
        node.start_auto_replication().await?;
    
        // Lens: index posts by id, by author, and by tag. We use prefixed keys.
        node.open_lens("feed-index").await?;
    
        let did = node.identity().did().to_string();
        println!("== feed node {id} ==");
        println!("did: {did}");
        println!();
        println!("commands:");
        println!("  post <body>            - publish a post");
        println!("  ls                     - list local posts");
        println!("  by <author-did>        - posts by author");
        println!("  tag <#tag>             - posts with tag");
        println!("  quit");
        println!();
    
        let stdin = tokio::io::stdin();
        let mut lines = BufReader::new(stdin).lines();
    
        while let Some(line) = lines.next_line().await? {
            let trimmed = line.trim();
            if let Some(body) = trimmed.strip_prefix("post ") {
                publish_post(&node, &did, body).await?;
            } else if trimmed == "ls" {
                list_posts(&node).await?;
            } else if let Some(author) = trimmed.strip_prefix("by ") {
                posts_by_author(&node, author).await?;
            } else if let Some(tag) = trimmed.strip_prefix("tag ") {
                posts_by_tag(&node, tag).await?;
            } else if trimmed == "quit" {
                break;
            }
        }
    
        Ok(())
    }
    
    async fn publish_post(node: &WeaveNode, did: &str, body: &str) -> Result<()> {
        let ts_ms = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)?
            .as_millis() as u64;
    
        let tags = body
            .split_whitespace()
            .filter(|w| w.starts_with('#'))
            .map(|w| w.to_string())
            .collect::<Vec<_>>();
    
        let mut id_hasher = blake3::Hasher::new();
        id_hasher.update(did.as_bytes());
        id_hasher.update(body.as_bytes());
        id_hasher.update(&ts_ms.to_be_bytes());
        let id = id_hasher.finalize().to_hex().to_string();
    
        let post = Post { id: id.clone(), author: did.to_string(), body: body.to_string(), ts_ms, tags };
        let bytes = serde_json::to_vec(&post)?;
    
        // Strand-of-record for the author's posts.
        let strand_name = format!("posts-{}", did);
        if node.strands().read().await.get(&strand_name).is_none() {
            node.create_strand(&strand_name).await?;
            node.announce_strand(&strand_name).await?;
        }
        let seq = node.append(&strand_name, &bytes).await?;
    
        // Index entries.
        node.lens_put("feed-index", index_key("id", &id), &bytes).await?;
        node.lens_put("feed-index", index_key("author", &format!("{did}:{seq}")), id.as_bytes()).await?;
        for tag in &post.tags {
            node.lens_put("feed-index", index_key("tag", &format!("{tag}:{id}")), id.as_bytes()).await?;
        }
    
        println!("posted {id}");
        Ok(())
    }
    
    fn index_key(kind: &str, suffix: &str) -> Vec<u8> {
        format!("{kind}/{suffix}").into_bytes()
    }
    
    async fn list_posts(node: &WeaveNode) -> Result<()> {
        // Naive: walk known authors via their Strands. In production, replace with
        // a Lens range scan over "id/".
        let strand_names: Vec<String> = node.strands().read().await.list().iter().map(|s| s.to_string()).collect();
        for name in strand_names {
            if let Some(_strand) = node.strands().read().await.get(&name) {
                println!("== {name} ==");
            }
        }
        Ok(())
    }
    
    async fn posts_by_author(node: &WeaveNode, author: &str) -> Result<()> {
        let strand_name = format!("posts-{author}");
        let strands = node.strands().read().await;
        if let Some(strand) = strands.get(&strand_name) {
            let len = strand.len();
            for i in 0..len {
                if let Ok(bytes) = strand.get(i).await {
                    if let Ok(post) = serde_json::from_slice::<Post>(&bytes) {
                        println!("[{}] {}", post.ts_ms, post.body);
                    }
                }
            }
        } else {
            println!("(no posts known for {author})");
        }
        Ok(())
    }
    
    async fn posts_by_tag(node: &WeaveNode, tag: &str) -> Result<()> {
        // Range scan in the Lens over "tag/<tag>:".
        // For simplicity we look up known authors and filter locally.
        let strand_names: Vec<String> = node.strands().read().await.list().iter().map(|s| s.to_string()).collect();
        for name in strand_names {
            if !name.starts_with("posts-") { continue; }
            let strands = node.strands().read().await;
            if let Some(strand) = strands.get(&name) {
                let len = strand.len();
                for i in 0..len {
                    if let Ok(bytes) = strand.get(i).await {
                        if let Ok(post) = serde_json::from_slice::<Post>(&bytes) {
                            if post.tags.iter().any(|t| t == tag) {
                                println!("[{}] {}: {}", post.ts_ms, post.author, post.body);
                            }
                        }
                    }
                }
            }
        }
        Ok(())
    }
  4. 4
    Run two nodes
    # terminal 1
    cargo run -- node-a
    # terminal 2
    cargo run -- node-b
  5. 5
    Post and query

    In node-a:

    > post Hello world #intro
    posted 3ec...

    In node-b, once the Strand replicates:

    > by did:l1fe:node-a:...
    [1715600000000] Hello world #intro
    > tag #intro
    [1715600000000] did:l1fe:node-a:...: Hello world #intro

How the primitives compose

  • One Strand per author keeps a signed, replay-safe timeline. Other peers replicate the strand and verify signatures before accepting blocks.
  • The Lens is a local secondary index. Range scans over tag/<tag>: prefix would be the production query; the example uses a linear scan for clarity.
  • weave-swarm + weave-dht handle author discovery and Strand replication.

Crash safety

Note

A post is a Strand block. If the process crashes between node.append and the Lens writes, the post is durable but the index is missing the entry. The Strand is the source of truth; the Lens is a rebuildable derived view.

Either:

  1. Replay the index on startup by walking the author's Strand once.
  2. Wrap publish in a try-block that re-indexes from the Strand on every restart.

The reference design replays into the index on cold start.

What to do next

  • Replace the linear scans with Lens range queries (lens_iter_prefix).
  • Add a follow list using Gnosis triples ((node-a, follows, node-b)).
  • Encrypt private posts using zer0-secret-stream and address them to a follower DID.
  • Try Semantic Search — same ingestion shape, but with vector search.