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
- 1Scaffold the project
cargo new --bin social-feed cd social-feed - 2Declare 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" - 3Write 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(()) } - 4Run two nodes
# terminal 1 cargo run -- node-a# terminal 2 cargo run -- node-b - 5Post 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:
- Replay the index on startup by walking the author's Strand once.
- 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-streamand address them to a follower DID. - Try Semantic Search — same ingestion shape, but with vector search.