Into the fediverse, part 2: it actually works

Part 1 ended badly. Astro’s middleware refused to load Fedify, and I shelved the whole idea with a vague promise to “host it separately another day.” A year later I finally did, and the second attempt worked. But it almost didn’t, because of a bug that logged nothing and errored nowhere, and I spent most of my debugging time being wrong about where it was.

Giving Fedify its own process

The plan from Part 1 was to stop fighting Astro’s middleware and give Fedify a process of its own. So the blog now has a sibling, a small SSR Astro service running on Deno, exposing an ActivityPub actor (@yuris@fedi.yuris.dev), mirroring published posts as Notes, and collecting replies as comments on those posts.

The two sides barely interact. When the deploy pipeline publishes a post, it POSTs it to the service. When a browser opens a blog post, the page fetches the service’s comments endpoint client-side and renders whatever’s there. That’s all the integration there is.

I put it in the same repo as fedify/ next to site/, sharing nothing but root-level tooling. The two Astro projects are on different majors with different import maps, so I deliberately keep them from touching. Deploys are separate too. The blog still ships to Cloudflare Pages exactly as before, and the service runs on my VPS under Dokploy, with its own Dockerfile and a Deno KV volume so keypairs, followers, posts, and comments survive redeploys.

Why not one Astro project?

Why not just one project, then? The original blocker is even gone; the Fedify issue I cited in Part 1, the Astro integration request, closed while I was building this, and @fedify/astro now ships a fedifyMiddleware() that hooks into Astro’s chain properly.

Mainly because I like the blog where it is. It’s output: "static", prerendered files on Cloudflare Pages, which is free, and I’d rather keep it that way. Fedify needs the opposite, a live Deno server with Deno KV, since WebFinger, actor, and inbox requests can’t be prerendered. Astro does support mixing both in one server project, but then the blog needs a server too.

So the setup stayed two projects, and with DNS pointed at fedi.yuris.dev and the container up, the only thing left was to follow myself from Mastodon, which I’d been waiting a year to do.

The follow that never got accepted

I hit follow on @yuris@fedi.yuris.dev and watched the button flip to “requested.”

It stayed there. On both ends, indefinitely.

Federation is annoying to debug for this exact reason. When Mastodon shows you “requested,” all it means is it sent the Follow activity and hasn’t seen an Accept come back. The silence could be my server, its server, or any hop between them. So I started ruling things out.

Cloudflare was the obvious first suspect, since it sits in front of everything I host, but bypassing it and hitting the origin IP directly changed nothing. Traefik, the reverse proxy inside the VPS, had a routing table that looked right, and test requests through it reached the container. DNS resolved correctly from outside. The auto-accept logic for inbound Follows was the same code I’d tested in Part 1, untouched.

Then I checked the container logs and found the actual problem. Not a single POST had ever arrived at /users/yuris/inbox. Every layer insisted the request was fine, and the application insisted it never happened.

With everything in front of the app ruled out, the next step was to drop the network entirely and reproduce locally:

$ curl -X POST http://localhost:8085/users/yuris/inbox -d '{"type":"Follow"}'
Method Not Allowed

Same result, no proxy or DNS or Cloudflare involved.

A few more curls made it clearer. Every POST to any path returned the same flat 405, including paths that didn’t exist. A missing route should 404. If a nonexistent path 405s, something is rejecting the method before any routing decision happens, so the problem had to be upstream of my code.

The culprit: @deno/astro-adapter

It turned out to be @deno/astro-adapter, specifically its fallback path.

When a request matches no Astro route, the adapter falls back to serving a static file off disk, using @std/http’s serveFile(). That function only understands GET and HEAD; for anything else it returns a bare 405 before Astro’s middleware, and Fedify inside it, ever sees the request. Fedify’s inbox handlers live in that middleware, so they never ran.

This also explains why half the service appeared to work. GETs were fine, because serveFile() answers a missing file with a 404, and a 404 falls through to the middleware chain where Fedify picks it up. POSTs, which are what the fediverse actually runs on, were dying one layer earlier, silently, with nothing logged. The bug hadn’t just eaten my follow; it had been eating every inbound Create from the start, which is why replies-as-comments had never worked either.

The fix feels almost silly next to all that. Two near-empty API route files, at /inbox and /users/[identifier]/inbox, so the requests now match a route and the adapter hands them to Astro’s middleware instead of the file server. Fedify intercepts them there, before the route handler ever runs.

src/pages/inbox.ts
export const prerender = false;
export const GET: APIRoute = () => new Response(null, { status: 404 });
export const POST: APIRoute = () => new Response(null, { status: 404 });

After redeploying and refollowing, the Accept came back in under a second.

Also, one more reason I don’t regret keeping this off the blog’s own project. The blog’s routes never go through this fallback.

The rest of the cleanup

Once inbound traffic could actually arrive, a few smaller gaps I’d never been able to test surfaced too.

  • New posts went out to followers as Creates, but a fresh follower got nothing until my next post. I added a setOutboxDispatcher backed by the same KV post records, then force-redelivered everything existing as fresh Creates so the first follow pays off immediately.
  • The actor had no avatar, so I set the blog’s logo as its icon.
  • /notes/{slug} speaks only JSON-LD and 406’d in a browser. It now redirects, so ActivityPub clients still get the object while humans land on the blog post.
  • The comments section used to hide itself until at least one comment existed. It always renders now, with a one-line prompt to be the first.

None of these were hard to fix. I just couldn’t see them until the pipe was open.

Federation working

You can follow @yuris@fedi.yuris.dev from Mastodon or any other ActivityPub client. New posts show up in timelines, and replies land as comments on the blog. The first attempt blocked on missing middleware support; the second came down to bypassing a static file fallback in @deno/astro-adapter.

Comments

Replies from the fediverse to @yuris@fedi.yuris.dev's post about this article.

No replies yet — open the post above from any Mastodon or fediverse app, reply to it, and your reply will appear here.