Fedify 2.0.0: Modular architecture, debug dashboard, and relay support · fedify-dev fedify · Discussion #580

18 min read Original article ↗

Fedify is a TypeScript framework for building ActivityPub servers that participate in the fediverse. It reduces the complexity and boilerplate typically required for ActivityPub implementation while providing comprehensive federation capabilities.

We are thrilled to announce Fedify 2.0.0, the most significant release in Fedify's history. This major version brings a fundamentally restructured modular architecture, a real-time debug dashboard, ActivityPub relay support, ordered message delivery, permanent failure handling, and many more improvements across the entire ecosystem.

Fedify 2.0.0 is the culmination of months of collaborative effort from the Fedify community, including significant contributions from Korea's OSSCA (Open Source Contribution Academy) participants. This release includes breaking changes that require careful migration—please review the Migration guide section below.

Breaking changes at a glance

Before diving into the new features, here is a summary of breaking changes that require attention when upgrading from Fedify 1.x:

Modular architecture

Fedify 2.0.0 introduces a fundamental restructuring of the package architecture. What was previously a monolithic @fedify/fedify package with @fedify/fedify/vocab, @fedify/fedify/runtime, and @fedify/fedify/x/* submodules has been split into focused, independent packages:

Old import New package Purpose
@fedify/fedify/vocab @fedify/vocab Activity Vocabulary type-safe classes
@fedify/fedify/runtime @fedify/vocab-runtime Vocabulary runtime infrastructure
@fedify/fedify/x/hono @fedify/hono Hono integration
@fedify/fedify/x/sveltekit @fedify/sveltekit SvelteKit integration
@fedify/fedify/x/denokv @fedify/denokv Deno KV adapter
@fedify/fedify/x/cfworkers @fedify/cfworkers Cloudflare Workers adapter
@fedify/fedify/x/fresh @fedify/fresh Fresh 2.0 integration (new)

The old import paths (@fedify/fedify/vocab and @fedify/fedify/runtime) still work as re-exports for backward compatibility, but they are deprecated and will be removed in a future version. The @fedify/fedify/x/* modules have been fully removed—you must migrate to the dedicated packages.

This modularization was primarily contributed by ChanHaeng Lee (@2chanhaeng).

@fedify/vocab: Activity Vocabulary package

The generated Activity Vocabulary classes (e.g., Create, Note, Person, Follow) are now in the standalone @fedify/vocab package. This separation enables:

  • Smaller bundle sizes for applications that only need vocabulary types
  • Independent versioning of vocabulary definitions
  • Custom vocabulary extensions via the new @fedify/vocab-tools package
// Before (still works but deprecated):
import { Create, Note } from "@fedify/fedify/vocab";

// After:
import { Create, Note } from "@fedify/vocab";

@fedify/vocab-runtime: Vocabulary runtime package

Core runtime utilities for vocabulary processing—DocumentLoader, LanguageString, cryptographic key utilities, and multibase encoding—have been extracted into @fedify/vocab-runtime:

// Before (still works but deprecated):
import { LanguageString } from "@fedify/fedify/runtime";

// After:
import { LanguageString } from "@fedify/vocab-runtime";

Note that @fedify/vocab re-exports LanguageString, DocumentLoader, and RemoteDocument from @fedify/vocab-runtime, so downstream consumers typically do not need to depend on @fedify/vocab-runtime directly.

@fedify/vocab-tools: Custom vocabulary generation

The new @fedify/vocab-tools package provides the code generation infrastructure that Fedify itself uses to generate Activity Vocabulary classes. This enables you to extend ActivityPub with custom vocabulary types:

  • Runtime-agnostic: works on Deno, Node.js, and Bun
  • Programmatic API for generating vocabulary classes from YAML schema files
  • Integrated with the new fedify generate-vocab CLI command

@fedify/webfinger: Standalone WebFinger client

WebFinger functionality has been extracted into a standalone package for applications that need WebFinger lookup without the full Fedify framework:

import { lookupWebFinger } from "@fedify/webfinger";

const result = await lookupWebFinger("@user@example.com");

@fedify/fresh: Fresh 2.0 integration

The deprecated @fedify/fedify/x/fresh module (designed for Fresh 1.x) has been replaced by the new @fedify/fresh package with full Fresh 2.0 support:

import { integrateHandler } from "@fedify/fresh";
import { federation } from "./federation.ts";

export const handler = integrateHandler(federation, () => undefined);

This was contributed by Hyeonseo Kim (@dodok8).

Real-time debug dashboard

Traces list page of the debug dashboard Trace detail page of the debug dashboard

Fedify 2.0.0 introduces @fedify/debugger, an embedded real-time ActivityPub debug dashboard that provides unprecedented visibility into your federation traffic during development.

Quick setup

import { createFederation } from "@fedify/fedify";
import { createFederationDebugger } from "@fedify/debugger";

const innerFederation = createFederation({ /* ... */ });
const federation = createFederationDebugger(innerFederation);
// Use `federation` as a drop-in replacement—the dashboard is at /__debug__/

That's it. createFederationDebugger() wraps your existing Federation object and automatically sets up OpenTelemetry tracing, span export, and LogTape integration—no manual configuration needed.

Dashboard features

The debug dashboard, accessible at /__debug__/ by default, provides:

  • Traces list: All captured ActivityPub traces with trace IDs, activity types, activity count, and timestamps, with auto-polling for real-time updates
  • Trace detail: Per-trace view showing activity direction (inbound/outbound), type, actor, inbox URL, signature verification details (HTTP Signatures, LD Signatures), expandable activity JSON, and log records with level, category, and message
  • JSON API: Programmatic access at /__debug__/api/traces and /__debug__/api/logs/:traceId

Authentication

Protect the dashboard in shared environments with built-in authentication:

const federation = createFederationDebugger(innerFederation, {
  auth: { password: "my-secret" },
  // Or: auth: { username: "admin", password: "secret" }
  // Or: auth: (request) => request.headers.get("X-Forwarded-For") === "127.0.0.1"
});

LogTape integration

The debugger automatically captures LogTape log records grouped by trace ID. In the simplified setup (without explicit exporter), LogTape is auto-configured. For advanced setups, the returned object includes a sink property for manual LogTape configuration.

To support this, Fedify now injects traceId and spanId into the LogTape context during request handling and queue processing, enabling log correlation with OpenTelemetry traces (#561, #564).

ActivityPub relay support

Fedify 2.0.0 introduces first-class ActivityPub relay support through the new @fedify/relay package and the fedify relay CLI command.

@fedify/relay package

ActivityPub relays are critical fediverse infrastructure that help smaller instances participate in content distribution. The new @fedify/relay package provides a ready-to-use relay server implementation:

import { createRelay } from "@fedify/relay";
import { MemoryKvStore } from "@fedify/fedify";

const relay = createRelay("mastodon", {
  kv: new MemoryKvStore(),
  origin: new URL("https://relay.example.com"),
  subscriptionHandler: async (ctx, subscriber) => {
    // Approve or reject subscriptions
    return "accepted";
  },
});

// Use relay.fetch() to handle incoming requests

The package supports two relay protocols as defined in FEP-ae0c:

  • Mastodon-style ("mastodon"): Direct activity forwarding with one-way following and immediate subscription acceptance. Forwards Create, Update, Delete, Move, and Announce activities. Broader fediverse compatibility.
  • LitePub-style ("litepub"): Activities wrapped in Announce, bidirectional following with pending-then-accepted state. Designed for LitePub-aware servers.

Relay management features include subscriber listing (relay.listFollowers()), individual subscriber lookup (relay.getFollower()), and automatic signature verification.

fedify relay CLI command

For quick testing and development, the new fedify relay command spins up an ephemeral relay server:

# Start a Mastodon-compatible relay with public tunnel
fedify relay

# LitePub relay with persistent storage
fedify relay --protocol litepub --persistent ./relay.db

# Accept only specific instances
fedify relay --accept-follow "mastodon.social,hachyderm.io"

# Reject specific instances
fedify relay --reject-follow "spam.example.com"

By default, the relay server is tunneled to the public internet for external access. Use --no-tunnel to run locally only.

This feature was primarily contributed by Jiwon Kwon (@sij411).

Ordered message delivery

One of the most impactful changes in Fedify 2.0.0 is the introduction of ordering keys for message queues, solving the long-standing “zombie post” problem in ActivityPub federation (#536).

The problem

When a post is created and then quickly deleted, the Delete activity can arrive at remote instances before the Create activity due to parallel message processing. This results in “zombie posts”—content that should have been deleted but persists because the delete was processed before the create.

The solution

The new orderingKey option in MessageQueueEnqueueOptions guarantees FIFO processing for messages sharing the same key, while allowing messages with different keys to be processed in parallel:

// Activities for the same note are delivered in order
await ctx.sendActivity(sender, recipients, createNote, {
  orderingKey: noteId,
});
await ctx.sendActivity(sender, recipients, deleteNote, {
  orderingKey: noteId,
});

When orderingKey is specified in SendActivityOptions, the key is automatically transformed to ${orderingKey}\n${recipientServerOrigin} during fan-out, ensuring per-recipient-server ordering while maintaining cross-server parallelism.

Backend support

All official MessageQueue implementations have been updated:

Backend Ordering mechanism
InProcessMessageQueue Built-in FIFO per key
PostgresMessageQueue SELECT FOR UPDATE SKIP LOCKED
SqliteMessageQueue Row-level ordering
RedisMessageQueue Redis Streams
AmqpMessageQueue rabbitmq_consistent_hash_exchange plugin
WorkersMessageQueue Workers KV locks (best-effort)

Note for custom implementations: If you have a custom MessageQueue implementation, you must add support for the orderingKey option in the enqueue() method. Messages with the same orderingKey must be processed in FIFO order.

Permanent delivery failure handling

Fedify 2.0.0 introduces a mechanism for handling permanent delivery failures when sending activities to remote inboxes (#548, #559).

The problem

Previously, when a remote inbox returned 410 Gone or 404 Not Found, Fedify treated it as a transient failure and continued retrying. This wasted resources and provided no way for applications to clean up unreachable followers.

setOutboxPermanentFailureHandler()

The new setOutboxPermanentFailureHandler() method lets you react to permanent failures:

federation.setOutboxPermanentFailureHandler(async (ctx, values) => {
  const { inbox, activity, error, statusCode, actorIds } = values;
  // Clean up followers pointing to the failed inbox
  for (const actorId of actorIds) {
    await removeFollower(actorId, inbox);
  }
});

The handler receives:

  • inbox: The failing inbox URL
  • activity: The Activity object that failed to deliver
  • error: A SendActivityError instance with HTTP status code and response details
  • statusCode: The HTTP status code
  • actorIds: Actor IDs intended to receive the activity at this inbox (relevant for shared inbox delivery)

Configuration

By default, HTTP status codes 404 and 410 are treated as permanent failures. Customize this via permanentFailureStatusCodes:

const federation = createFederation({
  // ...
  permanentFailureStatusCodes: [404, 410, 451],  // Add 451 Unavailable For Legal Reasons
});

SendActivityError

The new SendActivityError class provides structured error information for delivery failures, including the HTTP status code, inbox URL, and response body (limited to 1 KiB to prevent memory pressure from large error pages) (#569).

Content negotiation at middleware level

Fedify 2.0.0 moves content type negotiation from individual dispatchers to the middleware layer (#434, contributed by Emelia Smith). This is a breaking change that improves compatibility with applications serving both HTML and ActivityPub content from the same URLs.

What changed

Previously, actor, object, and collection dispatchers were called for all incoming requests, regardless of the Accept header. Applications had to handle content negotiation within dispatchers. Now, dispatchers are only invoked when the request accepts ActivityPub-compatible content types (application/activity+json, application/ld+json, etc.).

Impact

  • Requests with Accept: text/html (e.g., browser requests) no longer reach your dispatchers—they are passed through to your web framework
  • The onNotAcceptable callback is triggered at the middleware level before dispatchers are invoked
  • Applications that relied on dispatchers being called for all content types need to adjust their routing logic

This change simplifies the common pattern of serving both a web page and an ActivityPub representation at the same URL, as the framework now handles the routing decision automatically.

Default idempotency changed to "per-inbox"

The default activity idempotency strategy has changed from "per-origin" to "per-inbox" to align with standard ActivityPub behavior (#441).

Why this matters

In Fedify 1.x, activity deduplication was per-origin by default—the same activity ID would be processed only once per receiving server, regardless of how many inboxes on that server it was delivered to. This caused issues when:

  • The same activity was legitimately delivered to multiple personal inboxes on the same server
  • Software like Pixelfed reused activity IDs
  • Shared inbox implementations needed activities to reach each intended recipient independently

What changed

With "per-inbox" as the new default, each inbox independently tracks which activity IDs it has seen. The same activity can be processed once per inbox, which is the standard ActivityPub behavior.

To preserve the old behavior:

federation
  .setInboxListeners("/inbox/{identifier}", "/inbox")
  .withIdempotency("per-origin")  // Explicitly set old behavior
  .on(Follow, async (ctx, follow) => {
    // ...
  });

KvStore.list() is now required

The list() method on the KvStore interface, introduced as optional in Fedify 1.10.0, is now required in 2.0.0 (#499, #506).

interface KvStore {
  get(key: KvKey): Promise<unknown>;
  set(key: KvKey, value: unknown, options?: KvStoreSetOptions): Promise<void>;
  delete(key: KvKey): Promise<void>;
  // Now required:
  list(prefix?: KvKey): AsyncIterable<KvStoreListEntry>;
}

All official KvStore implementations already support this method: MemoryKvStore, SqliteKvStore, PostgresKvStore, RedisKvStore, DenoKvStore, and WorkersKvStore.

If you have a custom KvStore implementation, you must add a list() method that enumerates all entries whose keys start with the given prefix.

New @fedify/lint package

The new @fedify/lint package provides shared linting configurations for consistent code quality in Fedify-based projects (#297, #494, contributed by ChanHaeng Lee).

It supports both Deno Lint and ESLint, with 18 lint rules covering:

  • Actor property requirements (ID, inbox, outbox, followers, public keys, assertion methods)
  • URL pattern validation
  • Collection filtering implementation checks

Two presets are available: recommended (default) and strict.

New @fedify/create and @fedify/init packages

Creating new Fedify projects is now easier than ever with the new @fedify/create package (#351, contributed by ChanHaeng Lee):

npm init @fedify        # npm
pnpm create @fedify     # pnpm
yarn create @fedify     # yarn
bunx @fedify/create     # Bun

This provides the familiar npm init workflow that JavaScript developers expect, without needing to install the full @fedify/cli toolchain. The core initialization logic lives in the @fedify/init package, which is shared by both @fedify/create and the fedify init CLI command.

SqliteMessageQueue

The @fedify/sqlite package now includes SqliteMessageQueue, a MessageQueue implementation using SQLite as the backing store (#477, #526, contributed by ChanHaeng Lee). This is ideal for development environments and small-scale, single-node production deployments:

import { SqliteMessageQueue } from "@fedify/sqlite";

const queue = new SqliteMessageQueue("./queue.db");
await queue.initialize();

SqliteMessageQueue supports the new orderingKey option for ordered message delivery.

CLI enhancements

Native Node.js and Bun support

The Fedify CLI now runs natively on Node.js and Bun without requiring compiled binaries, providing a more natural JavaScript package experience (#374, #456, #457).

fedify generate-vocab command

Generate Activity Vocabulary classes from schema files using the new fedify generate-vocab command. This uses @fedify/vocab-tools internally and enables extending ActivityPub with custom vocabulary types (#444, #458, contributed by ChanHaeng Lee).

Improved fedify init

The fedify init command has been improved with better DX (#397, #435, contributed by ChanHaeng Lee):

fedify lookup --traverse

The fedify lookup command now supports traversing multiple collections in a single command with the -t/--traverse option (#408, #449, contributed by Jiwon Kwon).

#408: #408
#449: #449

--tunnel-service option

The fedify lookup, fedify inbox, and fedify relay commands now support a --tunnel-service option to select the tunneling service (localhost.run, serveo.net, or pinggy.io) (#525, #529, #531, contributed by Jiwon Kwon).

Configuration file support

The CLI now loads settings from TOML configuration files at multiple levels (#555, #566, contributed by Jiwon Kwon):

  • System-wide: /etc/xdg/fedify/config.toml
  • User-level: ~/.config/fedify/config.toml
  • Project-level: .fedify.toml
  • Custom: via --config option

All command options (inbox, lookup, webfinger, nodeinfo, tunnel, relay) can be configured through these files. Use --ignore-config to skip configuration file loading.

Other changes

Intl.Locale replaces LanguageTag

The @phensley/language-tag dependency has been replaced with the standardized Intl.Locale class (#280, #392, contributed by Jang Hanarae):

// Before:
const lang: LanguageTag = langString.language;

// After:
const locale: Intl.Locale = langString.locale;

NodeInfo version as string

NodeInfo software.version is now string instead of SemVer to properly handle non-SemVer version strings in accordance with the NodeInfo specification (#366, #433, contributed by Hyeonseo Kim). The parseSemVer() and formatSemVer() functions have been removed.

KvCacheParameters.rules type relaxed

The rules option type now accepts Temporal.DurationLike in addition to Temporal.Duration, making it easier to specify cache durations:

// Before: had to use Temporal.Duration.from()
rules: [[new URL("https://example.com"), Temporal.Duration.from({ hours: 1 })]],

// After: plain objects work too
rules: [[new URL("https://example.com"), { hours: 1 }]],

Bug fixes in database adapters

Testing utilities

The @fedify/testing package now includes testMessageQueue(), a reusable test harness for standardized testing of MessageQueue implementations (#477, #526, contributed by ChanHaeng Lee). It covers common operations including enqueue(), enqueue() with delay, enqueueMany(), multiple listeners, and (optionally) ordering key tests.

Elysia Deno support

The @fedify/elysia package now includes a deno.json configuration file for proper Deno tooling support (#460, #496).

Migration guide

Step 1: Update import paths

Replace deprecated import paths with new packages:

// Vocabulary types
// Before:
import { Create, Note, Person } from "@fedify/fedify/vocab";
// After:
import { Create, Note, Person } from "@fedify/vocab";

// Runtime utilities
// Before:
import { LanguageString } from "@fedify/fedify/runtime";
// After:
import { LanguageString } from "@fedify/vocab-runtime";

// Framework integrations
// Before:
import { federation } from "@fedify/fedify/x/hono";
// After:
import { federation } from "@fedify/hono";

Step 2: Replace removed APIs

// Before:
const federation = createFederation({
  documentLoader: myLoader,         // Removed
  contextLoader: myContextLoader,   // Removed
});

// After:
const federation = createFederation({
  documentLoaderFactory: (handle) => myLoader,
  contextLoaderFactory: (handle) => myContextLoader,
});

// Before:
import { fetchDocumentLoader } from "@fedify/fedify/runtime";
// After:
import { getDocumentLoader } from "@fedify/vocab-runtime";

// Before:
ctx.sendActivity({ handle: "alice" }, recipients, activity);
// After:
ctx.sendActivity({ identifier: "alice" }, recipients, activity);

Step 3: Update LanguageTag usage

// Before:
import { LanguageTag } from "@phensley/language-tag";
const lang: LanguageTag = langString.language;

// After:
const locale: Intl.Locale = langString.locale;
// Or construct from string:
const locale = new Intl.Locale("en-US");

Step 4: Update NodeInfo version handling

// Before:
import { parseSemVer } from "@fedify/fedify";
const version: SemVer = software.version;

// After:
const version: string = software.version;
// Parse yourself if needed: const parts = version.split(".");

Step 5: Review content negotiation

Dispatchers now only fire for requests with ActivityPub-compatible Accept headers. If your dispatcher contained logic for non-ActivityPub requests (e.g., rendering HTML or logging all visits), that code will no longer execute for browser requests:

// Before (1.x): Dispatcher was called for ALL requests, including browsers.
// Some apps relied on this for side effects or manual content negotiation:
federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  // This code ran even for Accept: text/html requests in 1.x.
  // In 2.0, this is ONLY called for ActivityPub content types.
  return new Person({ /* ... */ });
});

// After (2.0): If you need to handle both HTML and ActivityPub at the same URL,
// rely on the onNotAcceptable callback in your middleware integration:
return await federation.fetch(request, {
  contextData,
  onNotFound: async (request) => await next(request),
  onNotAcceptable: async (request) => {
    // Fedify calls this when the route matches but Accept is not ActivityPub.
    // Forward to your web framework to render HTML:
    const response = await next(request);
    if (response.status !== 404) return response;
    return new Response("Not Acceptable", {
      status: 406,
      headers: { "Content-Type": "text/plain", Vary: "Accept" },
    });
  },
});

Step 6: Set idempotency strategy explicitly (if needed)

// To keep the old 1.x behavior:
federation
  .setInboxListeners("/inbox/{identifier}", "/inbox")
  .withIdempotency("per-origin");

// Or accept the new default (recommended):
// "per-inbox" is now the default—no code change needed

Step 7: Implement KvStore.list() (custom implementations only)

If you have a custom KvStore implementation, add the list() method:

class MyKvStore implements KvStore {
  // ... existing methods ...

  async *list(prefix?: KvKey): AsyncIterable<KvStoreListEntry> {
    // Enumerate entries matching the prefix
    for (const [key, value] of this.entries()) {
      if (!prefix || keyStartsWith(key, prefix)) {
        yield { key, value };
      }
    }
  }
}

Acknowledgments

Fedify 2.0.0 represents an extraordinary collaborative effort. Special thanks to:

  • ChanHaeng Lee (@2chanhaeng) — Modular architecture, @fedify/vocab, @fedify/vocab-runtime, @fedify/vocab-tools, @fedify/init, @fedify/create, @fedify/lint, @fedify/fedify/x/* separation
  • Jiwon Kwon (@sij411) — @fedify/relay, fedify relay command, fedify lookup --traverse, --tunnel-service option, CLI configuration files, Redis race condition fix
  • Hyeonseo Kim (@dodok8) — @fedify/fresh (Fresh 2.0), Elysia framework support, NodeInfo version handling
  • Hasang Cho (@crohasang) — contextLoader/documentLoader factory migration
  • Jang Hanarae (@Palcimer) — Intl.Locale migration
  • Emelia Smith (@ThisIsMissEm) — Content negotiation middleware improvements

And to all community members who reported issues, provided feedback, and tested pre-release versions.

For the complete list of changes, bug fixes, and improvements, please refer to the CHANGES.md file in the repository.