diff --git a/.gitignore b/.gitignore
index cacdc217f..0e1d6b05b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ t.ts
t2.ts
plan.md
plans/
+.playwright-mcp/
diff --git a/.hongdown.toml b/.hongdown.toml
index 42c1d51da..ea8b5b563 100644
--- a/.hongdown.toml
+++ b/.hongdown.toml
@@ -40,6 +40,7 @@ proper_nouns = [
"ActivityPub",
"ActivityStreams",
"Akkoma",
+ "bun-types",
"BotKit",
"BrowserPub",
"Cloudflare Workers",
diff --git a/CHANGES.md b/CHANGES.md
index 0dae660bc..49d6a5e54 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -124,6 +124,21 @@ To be released.
[#656]: https://github.com/fedify-dev/fedify/pull/656
[#675]: https://github.com/fedify-dev/fedify/pull/675
+### Docs
+
+ - Added [*Building a federated blog* tutorial] showing how to layer
+ ActivityPub federation onto an [Astro] + [Bun] blog: actor setup,
+ follower management, SQLite persistence, sending `Create`/`Update`/
+ `Delete(Article)` activities on server startup, and receiving
+ `Create`/`Update`/`Delete(Note)` inbox activities as comments.
+ [[#691], [#695]]
+
+[*Building a federated blog* tutorial]: https://fedify.dev/tutorial/astro-blog
+[Astro]: https://astro.build/
+[Bun]: https://bun.sh/
+[#691]: https://github.com/fedify-dev/fedify/issues/691
+[#695]: https://github.com/fedify-dev/fedify/pull/695
+
Version 2.1.5
-------------
@@ -420,7 +435,6 @@ Released on March 24, 2026.
runtime-specific templates for Deno, Bun, and Node.js environments.
[[#50] by ChanHaeng Lee]
-[Astro]: https://astro.build/
[#50]: https://github.com/fedify-dev/fedify/issues/50
### @fedify/astro
diff --git a/deno.json b/deno.json
index 28f812a1d..8140dd1f1 100644
--- a/deno.json
+++ b/deno.json
@@ -100,7 +100,8 @@
"exclude": [
"**/*.html",
"**/*.md",
- "**/*.svg"
+ "**/*.svg",
+ ".playwright-mcp/**"
]
},
"nodeModulesDir": "auto",
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index ab4c3410f..f882d5854 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -95,6 +95,7 @@ const TUTORIAL = {
},
{ text: "Learning the basics", link: "/tutorial/basics.md" },
{ text: "Creating a microblog", link: "/tutorial/microblog.md" },
+ { text: "Building a federated blog", link: "/tutorial/astro-blog.md" },
],
activeMatch: "/tutorial",
};
diff --git a/docs/tutorial/astro-blog.md b/docs/tutorial/astro-blog.md
new file mode 100644
index 000000000..36e607d1b
--- /dev/null
+++ b/docs/tutorial/astro-blog.md
@@ -0,0 +1,2926 @@
+---
+description: >-
+ In this tutorial, we will build a federated blog that uses Astro to serve
+ blog posts from Markdown content and Fedify for ActivityPub federation,
+ allowing blog posts to be delivered to followers across the fediverse.
+---
+
+Building a federated blog
+=========================
+
+In this tutorial, we will build a [federated blog] using [Fedify] and [Astro].
+Blog posts are authored as [Markdown] files in Astro content collections and
+rendered on the server on request, while [ActivityPub] federation is handled
+by dynamic server routes.
+When you publish a new post (by deploying a new version of the site), your
+followers in the fediverse automatically receive it—no extra steps needed.
+Remote users can also reply to your posts from their own fediverse accounts,
+and those replies appear as comments on your blog.
+
+This tutorial focuses more on how to use Fedify than on understanding the
+underlying ActivityPub protocol. You'll see how Fedify handles the complex
+parts of federation for you.
+
+If you have any questions, suggestions, or feedback, please feel free to join
+our [Matrix chat space] or [GitHub Discussions].
+
+[federated blog]: https://en.wikipedia.org/wiki/Blog
+[Fedify]: https://fedify.dev/
+[Astro]: https://astro.build/
+[Markdown]: https://en.wikipedia.org/wiki/Markdown
+[ActivityPub]: https://www.w3.org/TR/activitypub/
+[Matrix chat space]: https://matrix.to/#/#fedify:matrix.org
+[GitHub Discussions]: https://github.com/fedify-dev/fedify/discussions
+
+
+Target audience
+---------------
+
+This tutorial is aimed at those who want to learn Fedify and build their own
+federated blog software.
+
+We assume that you have some experience creating web pages using HTML and
+basic JavaScript, and that you're comfortable using the command line.
+However, you don't need to know TypeScript, ActivityPub, or Fedify—we'll
+teach you what you need to know as we go along.
+
+You don't need experience building ActivityPub software, but we do assume
+that you've used at least one fediverse application such as Mastodon or
+Misskey. This way you'll have a feel for what we're trying to build.
+
+*[HTML]: HyperText Markup Language
+
+
+Goals
+-----
+
+In this tutorial, we'll use Fedify and Astro to create a single-author
+federated blog that communicates with other fediverse software via ActivityPub.
+The blog will include the following features:
+
+ - Blog posts are authored as Markdown files in *src/content/posts/*.
+ - The blog can be followed by other actors in the fediverse.
+ - A follower can unfollow the blog.
+ - When the blog is deployed with new posts, those posts are delivered to
+ all followers as ActivityPub activities.
+ - Remote users can reply to blog posts from their fediverse account.
+ - Replies appear as comments on the blog post page.
+
+To keep things focused, we'll impose the following limitations:
+
+ - The author's profile (bio, avatar, etc.) can only be changed by editing
+ source files.
+ - Post changes only propagate to followers on the next server restart;
+ there is no mechanism to push immediate edits or deletions.
+ - There are no likes or reposts.
+ - There is no search feature.
+ - There are no authentication or authorization features.
+
+The complete source code is available in the [GitHub repository], with commits
+corresponding to each step of this tutorial for your reference.
+
+[GitHub repository]: https://github.com/fedify-dev/astro-blog
+
+
+Setting up the development environment
+--------------------------------------
+
+### Installing Bun
+
+Fedify supports three JavaScript runtimes: [Deno], [Node.js], and [Bun].
+In this tutorial we'll use [Bun] because it includes a built-in SQLite driver
+(`bun:sqlite`) that we'll use later to store followers and comments.
+
+> [!TIP]
+> A JavaScript *runtime* is a platform that executes JavaScript code outside
+> of a web browser—on a server or in a terminal. Node.js was the original
+> server-side JavaScript runtime; Bun is a newer, faster alternative that also
+> comes with a built-in package manager and test runner.
+
+To install Bun, follow the instructions on the [Bun installation page].
+Once installed, verify it works:
+
+~~~~ sh
+bun --version
+~~~~
+
+You should see a version number such as `1.2.0` or later.
+
+[Deno]: https://deno.com/
+[Node.js]: https://nodejs.org/
+[Bun]: https://bun.sh/
+[Bun installation page]: https://bun.sh/docs/installation
+
+### Installing the `fedify` command
+
+To initialize a Fedify project you need the [`fedify`](../cli.md) command.
+Install it globally with:
+
+~~~~ sh
+bun install -g @fedify/cli
+~~~~
+
+Verify the installation:
+
+~~~~ sh
+fedify --version
+~~~~
+
+Make sure the version is 2.2.0 or higher.
+
+### `fedify init` to initialize the project
+
+Let's create a new directory for our blog and initialize the project.
+In this tutorial we'll call it *astro-blog*:
+
+~~~~ sh
+fedify init astro-blog
+~~~~
+
+When `fedify init` runs, it asks a series of questions.
+Select *Bun*, *Astro*, *In-memory*, and *In-process* in order:
+
+~~~~ console
+ ___ _____ _ _ __
+ /'_') | ___|__ __| (_)/ _|_ _
+ .-^^^-/ / | |_ / _ \/ _` | | |_| | | |
+ __/ / | _| __/ (_| | | _| |_| |
+ <__.|_|-|_| |_| \___|\__,_|_|_| \__, |
+ |___/
+
+? Choose the JavaScript runtime to use
+ Deno
+❯ Bun
+ Node.js
+
+? Choose the package manager to use
+❯ bun
+
+? Choose the web framework to integrate Fedify with
+ Bare-bones
+ Hono
+ Nitro
+ Next
+ Elysia
+❯ Astro
+ Express
+
+? Choose the key–value store to use for caching
+❯ In-memory
+ Redis
+ PostgreSQL
+
+? Choose the message queue to use for background jobs
+❯ In-process
+ Redis
+ PostgreSQL
+ AMQP (e.g., RabbitMQ)
+~~~~
+
+> [!NOTE]
+> Fedify is not a full-stack web framework—it's a library specialized for
+> implementing [ActivityPub] servers. You always use it alongside another
+> web framework. In this tutorial we use [Astro] with server-side rendering,
+> which lets us work with Markdown content collections and handle ActivityPub
+> endpoints all in the same application.
+
+After a moment, you'll have a working project with the following structure:
+
+ - *src/*
+ - *assets/* — Images and other static assets used in pages
+ - *components/* — Reusable Astro components
+ - *layouts/* — Page layout templates
+ - *pages/* — Routes (each *.astro* file becomes a URL)
+ - *index.astro* — The home page (`/`)
+ - *federation.ts* — ActivityPub server definition (the Fedify part)
+ - *logging.ts* — Logging configuration
+ - *middleware.ts* — Connects Fedify to Astro's request pipeline
+ - *public/* — Files served as-is (favicon, etc.)
+ - *astro.config.ts* — Astro configuration
+ - *biome.json* — Code formatter and linter settings
+ - *package.json* — Package metadata and scripts
+ - *tsconfig.json* — TypeScript settings
+
+Because we're using TypeScript instead of plain JavaScript, source files have
+*.ts* or *.astro* extensions. We'll cover the TypeScript-specific syntax you
+need as we go along.
+
+Let's verify the project works. First, install the dependencies:
+
+~~~~ sh
+cd astro-blog
+bun install
+~~~~
+
+Then start the development server:
+
+~~~~ sh
+bun run dev
+~~~~
+
+You should see output like this:
+
+~~~~ console
+ astro v6.x.x ready in xxx ms
+┃ Local http://localhost:4321/
+┃ Network use --host to expose
+~~~~
+
+Leave the server running and open a second terminal. Run this command to look
+up the demo actor that `fedify init` created:
+
+~~~~ sh
+fedify lookup http://localhost:4321/users/john
+~~~~
+
+If you see output like this, everything is working:
+
+~~~~ console
+✔ Looking up the object...
+Person {
+ id: URL "http://localhost:4321/users/john",
+ name: "john",
+ preferredUsername: "john"
+}
+~~~~
+
+This tells us there's an ActivityPub [*actor*][actor] at */users/john* on our
+server. An actor represents an account that can interact with other servers in
+the fediverse.
+
+> [!TIP]
+> [`fedify lookup`](../cli.md#fedify-lookup-looking-up-an-activitypub-object)
+> fetches and displays any ActivityPub object. It's like doing a fediverse
+> search from the command line.
+>
+> You can also use `curl` directly if you prefer:
+>
+> ~~~~ sh
+> curl -H "Accept: application/activity+json" \
+> http://localhost:4321/users/john | jq .
+> ~~~~
+>
+> The `-H "Accept: application/activity+json"` header tells Astro to
+> return the ActivityPub JSON representation of the page rather than the
+> HTML version. This is called *content negotiation*, and we'll cover it
+> in detail when we implement our actor.
+
+Stop the dev server with Ctrl+C for now.
+
+[actor]: https://www.w3.org/TR/activitypub/#actors
+
+### Visual Studio Code
+
+We recommend using [Visual Studio Code] while following this tutorial.
+TypeScript tooling works best in VS Code, and the generated project already
+includes settings for it.
+
+After [installing VS Code], open the project folder: *File* → *Open Folder…*.
+
+If a popup asks you to install the recommended Biome extension, click
+*Install*. Biome will automatically format your code on save, so you don't
+need to worry about indentation or code style.
+
+[Visual Studio Code]: https://code.visualstudio.com/
+[installing VS Code]: https://code.visualstudio.com/docs/setup/setup-overview
+
+
+Prerequisites
+-------------
+
+### TypeScript
+
+Before we start writing code, let's briefly go over TypeScript.
+If you're already familiar with TypeScript, feel free to skip this section.
+
+TypeScript is a superset of JavaScript that adds optional static type
+annotations. The syntax is almost identical to JavaScript; you just add type
+information after a colon (`:`).
+
+For example, this declares a variable `name` that must hold a string:
+
+~~~~ typescript twoslash
+let name: string = "Alice";
+~~~~
+
+If you try to assign a value of the wrong type, your editor will show a red
+underline *before you even run the code*:
+
+~~~~ typescript twoslash
+// @errors: 2322
+let name: string;
+// ---cut-before---
+name = 42; // ← red underline: Type 'number' is not assignable to type 'string'
+~~~~
+
+You can also annotate function parameters and return types:
+
+~~~~ typescript twoslash
+function greet(name: string): string {
+ return `Hello, ${name}!`;
+}
+~~~~
+
+Throughout this tutorial we'll encounter a few more TypeScript features and
+explain them as they appear. TypeScript knowledge isn't required—just pay
+attention to the red underlines in your editor and read the error messages.
+They're usually very helpful.
+
+
+Building the blog
+-----------------
+
+Now that the project is scaffolded, let's turn it into an actual blog.
+We'll use [Astro's content collections][content-collections] to manage blog
+posts as Markdown files, create a listing page, and add individual post pages.
+At the end of this chapter you'll have a working blog—no ActivityPub yet, just
+a clean static site.
+
+[content-collections]: https://docs.astro.build/en/guides/content-collections/
+
+### Defining the content collection
+
+Astro uses *content collections* to type-check and manage structured content
+like blog posts. Create the file *src/content.config.ts*:
+
+~~~~ typescript
+import { defineCollection, z } from "astro:content";
+import { glob } from "astro/loaders";
+
+const posts = defineCollection({
+ loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
+ schema: z.object({
+ title: z.string(),
+ pubDate: z.coerce.date(),
+ description: z.string(),
+ draft: z.boolean().optional(),
+ }),
+});
+
+export const collections = { posts };
+~~~~
+
+Let's walk through this:
+
+ - `defineCollection()` declares a named collection of content files.
+ - `glob(...)` tells Astro to find all _\*.md_ files in *src/content/posts/*.
+ - `z.object(...)` is a [Zod] schema that validates and types the frontmatter
+ in each Markdown file.
+
+> [!NOTE]
+> *Frontmatter* is the YAML block at the top of a Markdown file, enclosed
+> in `---`. It holds metadata like the title and publication date.
+
+The `z` object is a schema validation library called [Zod]. Each field in the
+schema corresponds to a frontmatter field in our Markdown posts. TypeScript
+will enforce that all posts have a `title`, `pubDate`, and `description`.
+
+[Zod]: https://zod.dev/
+
+### Writing blog posts
+
+Create three sample posts. First, *src/content/posts/hello-fediverse.md*:
+
+~~~~ markdown
+---
+title: "Hello, Fediverse!"
+pubDate: 2025-01-15
+description: >-
+ Welcome to this example federated blog built with Astro and Fedify.
+ You can follow it from Mastodon or any other fediverse platform.
+---
+
+Welcome to this federated blog example! ...
+~~~~
+
+Create two more posts—their exact content isn't important for the tutorial;
+what matters is that each post has valid frontmatter matching the schema.
+
+> [!TIP]
+> The `>-` syntax in YAML is a *block scalar*—it lets you write a long string
+> across multiple lines. Trailing newlines are stripped. This is handy for
+> `description` fields that would otherwise make the frontmatter too wide.
+
+### The layout component
+
+Replace *src/layouts/Layout.astro* with a minimal layout. The key parts are
+the `Props` interface (which TypeScript uses to type-check component usage) and
+a `
+ {handle} + · {followerCount} {followerCount === 1 ? "follower" : "followers"} +
+ ... +` tags rather than the full body) + - **`url`** — the canonical URL of the HTML page (same as `id` in our case) + - **`published`** — the publication date as a `Temporal.Instant` + +### Adding the posts table + +Open *src/lib/db.ts* and add the `posts` table at the end: + +~~~~ typescript twoslash [src/lib/db.ts] +// @noErrors +import { Database } from "bun:sqlite"; + +const db = new Database("blog.db"); + +db.run(` + CREATE TABLE IF NOT EXISTS key_pairs ( + identifier TEXT NOT NULL, + algorithm TEXT NOT NULL, + private_key BLOB NOT NULL, + public_key BLOB NOT NULL, + PRIMARY KEY (identifier, algorithm) + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS followers ( + actor_id TEXT PRIMARY KEY, + inbox_url TEXT NOT NULL + ) +`); +// ---cut-before--- +db.run(` + CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, + content_hash TEXT NOT NULL, + published_at TEXT NOT NULL + ) +`); + +export default db; +~~~~ + +The `id` column stores the Astro content collection slug (e.g., +`hello-fediverse`). `url` is the ActivityPub ID of the Article—it doubles +as the HTML page URL since we'll share the path `/posts/{slug}` between Astro +and Fedify via content negotiation. `content_hash` is a SHA-256 digest of the +title and body, used to detect edits. + +### Adding `@js-temporal/polyfill` + +The `Article` object uses `Temporal.Instant` from the [TC39 Temporal proposal] +polyfill. Although `@fedify/vocab` already depends on this package, you should +list it directly in your project so the version stays under your control: + +~~~~ sh +bun add @js-temporal/polyfill +~~~~ + +[TC39 Temporal proposal]: https://tc39.es/proposal-temporal/ + +### Adding an article object dispatcher + +When a remote server receives a `Create(Article)` activity, it will +dereference the `Article.id` URL to fetch the full object. Without an object +dispatcher, Fedify would return 404. + +Add the following dispatcher to *src/federation.ts* (just before +`export default federation`): + +~~~~ typescript twoslash [src/federation.ts] +// @noErrors +import { getCollection } from "astro:content"; +import { + createFederation, + generateCryptoKeyPair, + InProcessMessageQueue, + MemoryKvStore, +} from "@fedify/fedify"; +import { + Accept, + Article, + Endpoints, + Follow, + Person, + Undo, +} from "@fedify/vocab"; +import { Temporal } from "@js-temporal/polyfill"; +import { getLogger } from "@logtape/logtape"; +import { + addFollower, + getFollowers, + getKeyPairs, + removeFollower, + saveKeyPairs, +} from "./lib/store.ts"; + +const logger = getLogger("astro-blog"); + +export const BLOG_IDENTIFIER = "blog"; +export const BLOG_NAME = "Fedify Blog Example"; +export const BLOG_SUMMARY = + "A sample federated blog powered by Fedify and Astro."; + +const federation = createFederation({ + kv: new MemoryKvStore(), + queue: new InProcessMessageQueue(), +}); + +federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== BLOG_IDENTIFIER) { + logger.debug("Unknown actor identifier: {identifier}", { identifier }); + return null; + } + const kp = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: BLOG_NAME, + summary: BLOG_SUMMARY, + url: new URL("/", ctx.url), + inbox: ctx.getInboxUri(identifier), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + followers: ctx.getFollowersUri(identifier), + publicKey: kp[0].cryptographicKey, + assertionMethods: kp.map((k) => k.multikey), + }); + }) + .setKeyPairsDispatcher(async (_ctx, identifier) => { + if (identifier !== BLOG_IDENTIFIER) return []; + const stored = await getKeyPairs(identifier); + if (stored) return stored; + const [rsaKey, ed25519Key] = await Promise.all([ + generateCryptoKeyPair("RSASSA-PKCS1-v1_5"), + generateCryptoKeyPair("Ed25519"), + ]); + const kp = [rsaKey, ed25519Key]; + await saveKeyPairs(identifier, kp); + return kp; + }); + +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.actorId == null) return; + const parsed = ctx.parseUri(follow.objectId); + if (parsed?.type !== "actor" || parsed.identifier !== BLOG_IDENTIFIER) { + return; + } + const follower = await follow.getActor(ctx); + if (follower == null || follower.id == null || follower.inboxId == null) { + return; + } + addFollower(follower.id.href, follower.inboxId.href); + logger.info("New follower: {follower}", { follower: follower.id.href }); + await ctx.sendActivity( + { identifier: BLOG_IDENTIFIER }, + follower, + new Accept({ + id: new URL( + `#accepts/${follower.id.href}`, + ctx.getActorUri(BLOG_IDENTIFIER), + ), + actor: ctx.getActorUri(BLOG_IDENTIFIER), + object: follow, + }), + ); + }) + .on(Undo, async (ctx, undo) => { + const object = await undo.getObject(ctx); + if (!(object instanceof Follow)) return; + if (object.objectId?.href !== ctx.getActorUri(BLOG_IDENTIFIER).href) return; + if (undo.actorId == null) return; + removeFollower(undo.actorId.href); + logger.info("Unfollowed: {actor}", { actor: undo.actorId.href }); + }); + +federation.setFollowersDispatcher( + "/users/{identifier}/followers", + (_ctx, identifier) => { + if (identifier !== BLOG_IDENTIFIER) return null; + return { items: getFollowers() }; + }, +); +// ---cut-before--- +federation.setObjectDispatcher( + Article, + "/posts/{slug}", + async (ctx, { slug }) => { + const allPosts = await getCollection("posts"); + const post = allPosts.find((p) => p.id === slug && !p.data.draft); + if (!post) return null; + return new Article({ + id: ctx.getObjectUri(Article, { slug }), + attribution: ctx.getActorUri(BLOG_IDENTIFIER), + name: post.data.title, + summary: post.data.description, + content: `
${post.data.description}
`, + url: new URL(`/posts/${slug}`, ctx.url), + published: Temporal.Instant.from(post.data.pubDate.toISOString()), + }); + }, +); + +export default federation; +~~~~ + +`setObjectDispatcher` registers a path pattern (`/posts/{slug}`) and a +callback that returns the ActivityPub object for that path. The `{ slug }` +destructuring extracts the path parameter. + +`ctx.getObjectUri(Article, { slug })` generates the canonical ActivityPub +ID for the Article, e.g. `https://example.com/posts/hello-fediverse`. This +is the same URL as the HTML page—content negotiation (via the `Accept` header) +determines which representation is served: + + - Browser sends `Accept: text/html, */*` → Astro renders the HTML page + - ActivityPub client sends `Accept: application/activity+json` → Fedify + returns JSON-LD + +The `@fedify/astro` middleware handles this negotiation automatically. + +### Creating the publish module + +Create *src/lib/publish.ts*: + +~~~~ typescript twoslash [src/lib/publish.ts] +// @noErrors +import type { RequestContext } from "@fedify/fedify"; +import { Article, Create, Delete, Update } from "@fedify/vocab"; +import { Temporal } from "@js-temporal/polyfill"; +import { getCollection } from "astro:content"; +import { BLOG_IDENTIFIER } from "../federation.ts"; +import db from "./db.ts"; + +async function hashPost( + title: string, + description: string, + body: string, +): Promise${post.data.description}
`, + url: new URL(`/posts/${slug}`, ctx.url), + published: Temporal.Instant.from(post.data.pubDate.toISOString()), + }); + + if (!stored.has(slug)) { + await ctx.sendActivity( + { identifier: BLOG_IDENTIFIER }, + "followers", + new Create({ + id: new URL(`#create-${Date.now()}`, articleId), + actor: actorUri, + to: AS_PUBLIC, + object: article, + }), + ); + db.run( + `INSERT INTO posts (id, title, url, content_hash, published_at) + VALUES (?, ?, ?, ?, ?)`, + [ + slug, + post.data.title, + articleId.href, + contentHash, + post.data.pubDate.toISOString(), + ], + ); + } else if (stored.get(slug)?.content_hash !== contentHash) { + await ctx.sendActivity( + { identifier: BLOG_IDENTIFIER }, + "followers", + new Update({ + id: new URL(`#update-${Date.now()}`, articleId), + actor: actorUri, + to: AS_PUBLIC, + object: article, + }), + ); + db.run( + `UPDATE posts SET title = ?, content_hash = ?, published_at = ? + WHERE id = ?`, + [post.data.title, contentHash, post.data.pubDate.toISOString(), slug], + ); + } + } + + for (const [slug, row] of stored) { + if (!currentIds.has(slug)) { + await ctx.sendActivity( + { identifier: BLOG_IDENTIFIER }, + "followers", + new Delete({ + id: new URL(`#delete-${slug}-${Date.now()}`, actorUri), + actor: actorUri, + to: AS_PUBLIC, + object: new URL(row.url), + }), + ); + db.run("DELETE FROM posts WHERE id = ?", [slug]); + } + } +} +~~~~ + +A few things to unpack here: + +**`hashPost`** computes a SHA-256 digest of the title, description, and body +concatenated together. Any change to those fields will produce a different +hash, triggering an `Update(Article)` activity. + +**`AS_PUBLIC`** is the ActivityPub [public addressing] URL. Activities +addressed `to` this URL are publicly visible on fediverse clients. + +**`"followers"`** passed as the recipients argument tells Fedify to look up +the blog's followers collection and fan the activity out to all of them. +Fedify calls the followers dispatcher you registered earlier, so you no +longer need to call `getFollowers()` manually here. When the followers list +is empty, Fedify simply sends nothing. + +**The loop** iterates over current non-draft posts and checks the SQLite +table: + + - `!stored.has(slug)` → new post → `Create(Article)` + - `stored.get(slug)?.content_hash !== contentHash` → changed post → + `Update(Article)` + - Remaining slugs in `stored` that are not in `current` → deleted post → + `Delete(Article)` + +[public addressing]: https://www.w3.org/TR/activitypub/#public-addressing + +### Triggering the sync on startup + +Modify *src/middleware.ts* to call `syncPosts` once, on the first HTTP +request, right after the `X-Forwarded-Proto`/`X-Forwarded-Host` rewrite: + +~~~~ typescript twoslash [src/middleware.ts] +// @noErrors +import { fedifyMiddleware } from "@fedify/astro"; +import type { MiddlewareHandler } from "astro"; +import federation from "./federation.ts"; +import { syncPosts } from "./lib/publish.ts"; +import "./logging.ts"; + +let synced = false; + +export const onRequest: MiddlewareHandler = (context, next) => { + // Rewrite the request URL based on X-Forwarded-Proto / X-Forwarded-Host + // when running behind a reverse proxy or tunnel (e.g. `fedify tunnel`). + const proto = context.request.headers.get("x-forwarded-proto"); + const host = context.request.headers.get("x-forwarded-host"); + const url = new URL(context.request.url); + if (proto != null && url.protocol !== `${proto}:`) url.protocol = proto; + if (host != null && url.host !== host) url.host = host; + if (proto != null || host != null) { + context.request = new Request(url.toString(), context.request); + } + if (!synced && context.request.headers.get("x-forwarded-host") != null) { + synced = true; + const ctx = federation.createContext(context.request, undefined); + syncPosts(ctx).catch((err) => { + console.error("Failed to sync posts:", err); + synced = false; + }); + } + return fedifyMiddleware(federation, (_ctx) => undefined)(context, next); +}; +~~~~ + +`federation.createContext(request, contextData)` creates a Fedify +[`RequestContext`] from the current HTTP request. The context knows the +server's public URL (including scheme and host), which it uses to generate +correct ActivityPub IDs for the activities it sends. Because the +`X-Forwarded-Proto`/`X-Forwarded-Host` rewrite already runs before this +point, `context.request` always carries the correct public URL when the +request arrives through the tunnel. + +`syncPosts(ctx)` is fired and *not* awaited, so it runs in the background +while the response is served immediately. The `synced` flag guards against +double-firing: it is set on the first request that arrives with an +`X-Forwarded-Host` header (i.e., the first tunnel request), and reset to +`false` if `syncPosts` throws so that a transient failure does not +permanently suppress activity delivery. + +> [!TIP] +> In production you could also trigger `syncPosts` from a startup script or a +> deploy hook. The fire-and-forget pattern shown here is simplest for a +> development tutorial. + +[`RequestContext`]: https://jsr.io/@fedify/fedify/doc/~/RequestContext + +### Testing + +Follow the blog from ActivityPub.Academy (tunnel still required). +Then add a new post file to *src/content/posts/*: + +~~~~ markdown [src/content/posts/new-post.md] +--- +title: "A new post" +pubDate: 2025-04-01 +description: "This post appears in followers' timelines." +--- + +Hello, followers! This is a new post sent via ActivityPub. +~~~~ + +Restart the dev server: + +~~~~ sh +bun run dev +~~~~ + +Then open your tunnel URL in a browser (e.g. +`https://your-tunnel-url.trycloudflare.com/`) so that the first request +arrives with the correct `X-Forwarded-Host` header and Fedify generates +the right public URLs for the activities it sends. + +Within seconds you should see a log line like: + +~~~~ console +18:42:02.456 INF @fedify/fedify Sent activity Create to ... +~~~~ + +Check your ActivityPub.Academy timeline—the new post should appear there. +The blog title and description are shown (we include the description as +`content`). + +To test updates, change the title or description of the new post and +restart. To test deletion, remove the file and restart. + + +Receiving and displaying comments +--------------------------------- + +Followers can now read our posts in their fediverse timelines. In this chapter +we'll make the conversation two-way: when someone replies to one of our posts +from Mastodon or another fediverse server, we'll store the reply and display it +below the post. + +### Adding the comments table + +Open *src/lib/db.ts* and append a `comments` table: + +~~~~ typescript twoslash [src/lib/db.ts] +// @noErrors +import { Database } from "bun:sqlite"; + +const db = new Database("blog.db"); + +db.run(`CREATE TABLE IF NOT EXISTS key_pairs ( + identifier TEXT NOT NULL, algorithm TEXT NOT NULL, + private_key BLOB NOT NULL, public_key BLOB NOT NULL, + PRIMARY KEY (identifier, algorithm))`); +db.run(`CREATE TABLE IF NOT EXISTS followers ( + actor_id TEXT PRIMARY KEY, inbox_url TEXT NOT NULL)`); +db.run(`CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, title TEXT NOT NULL, url TEXT NOT NULL, + content_hash TEXT NOT NULL, published_at TEXT NOT NULL)`); +// ---cut-before--- +db.run(` + CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + post_id TEXT NOT NULL, + author_url TEXT NOT NULL, + author_name TEXT NOT NULL, + content TEXT NOT NULL, + published_at TEXT NOT NULL + ) +`); + +export default db; +~~~~ + +Each row represents one fediverse reply. `id` is the ActivityPub object ID +of the remote `Note`, and `post_id` is the slug of the local post it replies +to. + +### Adding comment helpers to the store + +Extend *src/lib/store.ts* with the comment CRUD functions that the inbox +handlers will call: + +~~~~ typescript twoslash [src/lib/store.ts] +// @noErrors +import db from "./db.ts"; +// ---cut-before--- +export interface Comment { + id: string; + postId: string; + authorUrl: string; + authorName: string; + content: string; + publishedAt: string; +} + +export function addComment(comment: Comment): void { + db.run( + `INSERT OR REPLACE INTO comments + (id, post_id, author_url, author_name, content, published_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + comment.id, + comment.postId, + comment.authorUrl, + comment.authorName, + comment.content, + comment.publishedAt, + ], + ); +} + +export function updateComment( + id: string, + authorName: string, + content: string, +): void { + db.run( + `UPDATE comments SET author_name = ?, content = ? WHERE id = ?`, + [authorName, content, id], + ); +} + +export function getCommentAuthorUrl(id: string): string | null { + return ( + db + .query<{ author_url: string }, [string]>( + `SELECT author_url FROM comments WHERE id = ?`, + ) + .get(id)?.author_url ?? null + ); +} + +export function deleteComment(id: string): void { + db.run(`DELETE FROM comments WHERE id = ?`, [id]); +} + +export function getCommentsByPost(postId: string): Comment[] { + return db + .query< + { + id: string; + author_url: string; + author_name: string; + content: string; + published_at: string; + }, + [string] + >( + `SELECT id, author_url, author_name, content, published_at + FROM comments WHERE post_id = ? ORDER BY published_at`, + ) + .all(postId) + .map((r) => ({ + id: r.id, + postId, + authorUrl: r.author_url, + authorName: r.author_name, + content: r.content, + publishedAt: r.published_at, + })); +} +~~~~ + +`addComment` uses `INSERT OR REPLACE` so that receiving the same activity +twice (e.g., retries) is idempotent. + +`getCommentAuthorUrl` is a small helper used by the `Update` and `Delete` +handlers to verify that the actor performing the operation is the original +author. + +### Handling inbox activities + +Open *src/federation.ts* and update the imports and inbox listeners to handle +`Create(Note)`, `Update(Note)`, and `Delete(Note)`: + +~~~~ typescript twoslash [src/federation.ts] +// @noErrors +import { getCollection } from "astro:content"; +import { + createFederation, + generateCryptoKeyPair, + InProcessMessageQueue, + MemoryKvStore, +} from "@fedify/fedify"; +import { + Accept, + Article, + Create, + Delete, + Endpoints, + Follow, + Note, + Person, + Undo, + Update, +} from "@fedify/vocab"; +import { Temporal } from "@js-temporal/polyfill"; +import { getLogger } from "@logtape/logtape"; +import { + addComment, + addFollower, + deleteComment, + getCommentAuthorUrl, + getFollowers, + getKeyPairs, + removeFollower, + saveKeyPairs, + updateComment, +} from "./lib/store.ts"; + +const logger = getLogger("astro-blog"); + +export const BLOG_IDENTIFIER = "blog"; +export const BLOG_NAME = "Fedify Blog Example"; +export const BLOG_SUMMARY = + "A sample federated blog powered by Fedify and Astro."; + +const federation = createFederation({ + kv: new MemoryKvStore(), + queue: new InProcessMessageQueue(), +}); + +federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== BLOG_IDENTIFIER) { + logger.debug("Unknown actor identifier: {identifier}", { identifier }); + return null; + } + const kp = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: BLOG_NAME, + summary: BLOG_SUMMARY, + url: new URL("/", ctx.url), + inbox: ctx.getInboxUri(identifier), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + followers: ctx.getFollowersUri(identifier), + publicKey: kp[0].cryptographicKey, + assertionMethods: kp.map((k) => k.multikey), + }); + }) + .setKeyPairsDispatcher(async (_ctx, identifier) => { + if (identifier !== BLOG_IDENTIFIER) return []; + const stored = await getKeyPairs(identifier); + if (stored) return stored; + const [rsaKey, ed25519Key] = await Promise.all([ + generateCryptoKeyPair("RSASSA-PKCS1-v1_5"), + generateCryptoKeyPair("Ed25519"), + ]); + const kp = [rsaKey, ed25519Key]; + await saveKeyPairs(identifier, kp); + return kp; + }); + +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.actorId == null) return; + const parsed = ctx.parseUri(follow.objectId); + if (parsed?.type !== "actor" || parsed.identifier !== BLOG_IDENTIFIER) { + return; + } + const follower = await follow.getActor(ctx); + if (follower == null || follower.id == null || follower.inboxId == null) { + return; + } + addFollower(follower.id.href, follower.inboxId.href); + logger.info("New follower: {follower}", { follower: follower.id.href }); + await ctx.sendActivity( + { identifier: BLOG_IDENTIFIER }, + follower, + new Accept({ + id: new URL( + `#accepts/${follower.id.href}`, + ctx.getActorUri(BLOG_IDENTIFIER), + ), + actor: ctx.getActorUri(BLOG_IDENTIFIER), + object: follow, + }), + ); + }) + .on(Undo, async (ctx, undo) => { + const object = await undo.getObject(ctx); + if (!(object instanceof Follow)) return; + if (object.objectId?.href !== ctx.getActorUri(BLOG_IDENTIFIER).href) return; + if (undo.actorId == null) return; + removeFollower(undo.actorId.href); + logger.info("Unfollowed: {actor}", { actor: undo.actorId.href }); + }) + .on(Create, async (ctx, create) => { + const object = await create.getObject(ctx); + if (!(object instanceof Note)) return; + if (object.id == null || create.actorId == null) return; + const replyTargetId = object.replyTargetId; + if (replyTargetId == null) return; + const parsed = ctx.parseUri(replyTargetId); + if (parsed?.type !== "object" || parsed.class !== Article) return; + const { slug } = parsed.values; + const allPosts = await getCollection("posts"); + if (!allPosts.some((p) => p.id === slug && !p.data.draft)) return; + const author = await create.getActor(ctx); + if (author == null || author.id == null) return; + const authorName = + author.name?.toString() ?? + author.preferredUsername?.toString() ?? + author.id.host; + addComment({ + id: object.id.href, + postId: slug, + authorUrl: author.id.href, + authorName, + content: object.content?.toString() ?? "", + publishedAt: ( + object.published ?? Temporal.Now.instant() + ).toString(), + }); + logger.info("New comment on /{slug} by {author}", { + slug, + author: author.id.href, + }); + }) + .on(Update, async (ctx, update) => { + const object = await update.getObject(ctx); + if (!(object instanceof Note)) return; + if (object.id == null || update.actorId == null) return; + const existing = getCommentAuthorUrl(object.id.href); + if (existing == null || existing !== update.actorId.href) return; + const author = await update.getActor(ctx); + const authorName = + author?.name?.toString() ?? + author?.preferredUsername?.toString() ?? + update.actorId.host; + updateComment( + object.id.href, + authorName, + object.content?.toString() ?? "", + ); + }) + .on(Delete, async (_ctx, delete_) => { + if (delete_.actorId == null) return; + const objectId = delete_.objectId; + if (objectId == null) return; + const existing = getCommentAuthorUrl(objectId.href); + if (existing == null || existing !== delete_.actorId.href) return; + deleteComment(objectId.href); + }); + +federation.setFollowersDispatcher( + "/users/{identifier}/followers", + (_ctx, identifier) => { + if (identifier !== BLOG_IDENTIFIER) return null; + return { items: getFollowers() }; + }, +); + +federation.setObjectDispatcher( + Article, + "/posts/{slug}", + async (ctx, { slug }) => { + const allPosts = await getCollection("posts"); + const post = allPosts.find((p) => p.id === slug && !p.data.draft); + if (!post) return null; + return new Article({ + id: ctx.getObjectUri(Article, { slug }), + attribution: ctx.getActorUri(BLOG_IDENTIFIER), + name: post.data.title, + summary: post.data.description, + content: `${post.data.description}
`, + url: new URL(`/posts/${slug}`, ctx.url), + published: Temporal.Instant.from(post.data.pubDate.toISOString()), + }); + }, +); + +export default federation; +~~~~ + +Let's walk through the three new handlers: + +`Create` +: Called when someone sends a reply: + + 1. Fetch the activity's `object` and verify it's a `Note`. + 2. Check `note.replyTargetId` (the `inReplyTo` URL) and parse it with + `ctx.parseUri`. If it matches our Article dispatcher pattern, we + get back `{ type: "object", class: Article, values: { slug: "…" } }`. + 3. Fetch the author actor to get their display name. + 4. Store the comment with `addComment`. + +`Update` +: Called when the author edits their reply: + + 1. Verify the note exists in our database. + 2. Verify the actor matches the stored `authorUrl` (no one else can + edit someone else's comment). + 3. Update the name and content. + +`Delete` +: Called when the author deletes their reply: + + 1. Check that the actor matches the stored author. + 2. Delete the row. + +The `Undo` handler ignores non-`Follow` objects, so it won't accidentally +remove comments when a follower unfollows. + +> [!WARNING] +> The `content` field on `Note` is HTML sent by a remote server. Storing +> and rendering it verbatim exposes your visitors to XSS attacks. See +> [*Security: sanitizing comment HTML*](#security-sanitizing-comment-html) for +> guidance on sanitization. + +### Displaying comments on the post page + +Replace *src/pages/posts/\[slug].astro* with the following: + +~~~~ astro +--- +import { getCollection, render } from "astro:content"; +import Layout from "../../layouts/Layout.astro"; +import { getCommentsByPost } from "../../lib/store.ts"; + +const { slug } = Astro.params; +const posts = await getCollection("posts"); +const post = posts.find((p) => p.id === slug); + +if (!post || post.data.draft) { + return new Response(null, { status: 404, statusText: "Not Found" }); +} + +const { Content } = await render(post); +const comments = getCommentsByPost(post.id); +--- + +
{comments.length} {comments.length === 1 ? "comment" : "comments"}
+ { + comments.length === 0 ? ( +No comments yet.
+ ) : ( ++ {comments.map((comment) => ( +-
+
+
+ {comment.authorName}
+
+
+
+ {/* Note: comment.content is HTML from remote servers.
+ Sanitize before rendering in production! */}
+
+
+ ))}
+
+ ) + } +