Integrating My Blog with Mastodon

Decentralization is cool again! Twitter is burning and people are fleeing to greener pastures. There are a bunch of alternatives, but the most promising one is Mastodon — not because of any particular feature, but because it’s built on an open standard called ActivityPub.

ActivityPub is a protocol: a bunch of conventions that apps can use to talk to each other. That means that if you build an app that understands ActivityPub, any other app that also understands it can communicate with it. Technically, this is called federation, not decentralization, and the ecosystem of apps that work together this way is lovingly referred to as the Fediverse.

I decided to dip my toes in and see how ActivityPub works. What I built is pretty simple: a tiny server that can interact with the rest of the Fediverse, plus a GitHub Actions workflow that posts whenever I publish a new blog post.

The server I made is called ActivityPub Starter Kit. It’s built with NodeJS and stores its data in SQLite. To make hacking on it easier, I also made a simpler version on Glitch. As soon as you remix it, you get an account that you can follow from any Fediverse app — no extra setup required!

In the rest of this blog post, I’ll show how easy it is to set up an integration between your blog and the Fediverse. We’ll assume that we’re using the Glitch app with an account called jake.

So: ActivityPub Starter Kit. It’s a very basic ActivityPub server with only a few endpoints. There are two that matter to us:1

  • /jake/outbox is the Outbox for our account (if you’re following along at home, replace jake with whatever account name you choose). The response contains a list of posts in reverse chronological order. We’ll use this to check which blog posts have already been posted to the Fediverse.
  • /admin/create adds a new post to the Outbox and notifies all our followers that we’ve.2

My blog is built on a static site generator that outputs an RSS feed. So there are four basic steps here:

  1. Get the latest post from the RSS feed.
  2. Get the latest post from the ActivityPub Outbox.
  3. Compare the URLs.
  4. If they don’t match, create a new ActivityPub post and notify followers.

There are only two libraries we need for this: node-fetch (to make HTTP requests more easily) and fast-xml-parser (to parse the RSS feed into JSON).

import { XMLParser } from "fast-xml-parser";
import fetch from "node-fetch";

There are a few variables we’ll need that we may as well define now:

const RSS_URL = "https://jakelazaroff.com/rss.xml";
const ACTIVITYPUB_HOST = "https://shine-thunder-forgery.glitch.me";
const OUTBOX_URL = ACTIVITYPUB_HOST + "/jake/outbox";
const CREATE_URL = ACTIVITYPUB_HOST + "/admin/create";
const ADMIN_USERNAME = "adminuser";
const ADMIN_PASSWORD = "secretpassword!";
  • RSS_URL is the URL of the RSS feed with the blog posts.
  • ACTIVITYPUB_HOST is the URL of the ActivityPub server (just the protocol and hostname, so we can build our other URLs).
  • OUTBOX_URL is the URL of the Outbox.
  • CREATE_URL is the URL of the endpoint we’ll use to actually create the post.
  • ADMIN_USERNAME and ADMIN_PASSWORD correspond to environment variables we can set in the server to prevent randos from creating posts.

Okay, onto the main event. First, we’ll fetch the RSS feed and get the latest blog post:

console.log(`Fetching RSS feed from ${RSS_URL}…`);
const rss = await fetch(RSS_URL);
const feed = new XMLParser().parse(await rss.text());
const latest = feed.rss.channel.item[0];

Next, we’ll fetch the ActivityPub feed and get the latest post there:

console.log(`Fetching ActivityPub feed from ${OUTBOX_URL}…`);
const outbox = await fetch(OUTBOX_URL);
const posts = await outbox.json();
const post = posts.orderedItems[0];

Now that we have the latest post from each, we can check to see if their URLs match:

if (latest.link === post.object?.url) console.log("No new post in feed.");

If they don’t match, we’ll make a new post in the ActivityPub feed:

else {
  console.log("Posting to ActivityPub feed…");

Before we can send the request, we need to calculate the Basic authentication header. That’s just the string Basic username:password, encoded in base 64:

const authorization = "Basic " + Buffer.from(ADMIN_USERNAME + ":" + ADMIN_PASSWORD).toString("base64");

We also need to create the request body. ActivityPub supports a bunch of different types of "Objects", but since this is a blog, the posts will all be of type Article. We’ll supply title, summary, content, url and published with data from the RSS feed:

const date = new Date(latest.pubDate);
const body = JSON.stringify({
  object: {
    type: "Article",
    url: latest.link,
    title: latest.title,
    summary: latest.description,
    content: latest["content:encoded"],
    published: date.toISOString(),
  },
});

You’ll also notice that we’re nesting everything inside an object property. That’s because each item in the Outbox is actually a Create Activity that contains an Object.3 That Activity has its own set of properties, and we can override them by including them in the request body.

For example, if we wanted to set the ActivityPub post’s date to be the same as the blog post, we could add published to the top level alongside object, like so:

const date = new Date(latest.pubDate);
const body = JSON.stringify({
  published: date.toISOString(),
  object: {
    type: "Article",
    url: latest.link,
    title: latest.title,
    summary: latest.description,
    content: latest["content:encoded"],
    published: date.toISOString(),
  },
});

After we have the body, all that’s left is to send off the request:

  const res = await fetch(CREATE_URL, {
    method: "POST",
    body,
    headers: { authorization, "content-type": "application/json" },
  });

  if (!res.ok) throw new Error(`Got ${res.status} ${res.statusText} when creating post.`);
}

And that’s it! People can now follow your blog in the Fediverse.

Here’s the whole script. It’s short — barely 40 lines. I included my full GitHub Actions deploy workflow in there, in case the context helps.

And of course, this is just one example of what to build. You could have your ActivityPub server follow people and turn their posts into a reading list or a blogroll. You could make a bot that looks up information, or uses ChatGPT to write poems for people. Check out ActivityPub Starter Kit and make something cool or quirky or fun!

Footnotes

  1. Technically, there’s also a third: /jake/inbox, our account’s Inbox. We don’t ever make requests to our own Inbox, though — other servers will do that when they want to notify us of something. When we make a new post, ActivityPub Starter Kit will go through each follower and make a request to their Inbox.

  2. /admin/create is not, strictly speaking, an ActivityPub endpoint, but it does follow the client-to-server interaction guidance on Object creation without the Create Activity.

  3. In ActivityPub parlance, an Actor (the account) performs an Activity (some sort of action) on an Object (a thing). Darius Kazemi has a good blog post breaking this down a bit more.