Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 61 additions & 17 deletions packages/relay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,35 @@ const relay = createRelay("mastodon", {
});
~~~~

### Managing followers

The relay provides methods to query and manage followers without exposing
internal storage details.

#### Listing all followers

~~~~ typescript
for await (const follower of relay.listFollowers()) {
console.log(`Follower: ${follower.actorId}`);
console.log(`State: ${follower.state}`);
console.log(`Actor name: ${follower.actor.name}`);
console.log(`Actor type: ${follower.actor.constructor.name}`);
}
~~~~

#### Getting a specific follower

~~~~ typescript
const follower = await relay.getFollower("https://mastodon.example.com/users/alice");
if (follower) {
console.log(`Found follower in state: ${follower.state}`);
console.log(`Actor username: ${follower.actor.preferredUsername}`);
console.log(`Inbox: ${follower.actor.inboxId?.href}`);
} else {
console.log("Follower not found");
}
~~~~

### Integration with web frameworks

The relay's `fetch()` method returns a standard `Response` object, making it
Expand Down Expand Up @@ -234,39 +263,36 @@ Factory function to create a relay instance.
function createRelay(
type: "mastodon" | "litepub",
options: RelayOptions
): BaseRelay
): Relay
~~~~

**Parameters:**

- `type`: The type of relay to create (`"mastodon"` or `"litepub"`)
- `options`: Configuration options for the relay

**Returns:** A relay instance (`MastodonRelay` or `LitePubRelay`)
**Returns:** A `Relay` instance

### `BaseRelay`
### `Relay`

Abstract base class for relay implementations.
Public interface for ActivityPub relay implementations.

#### Methods

- `fetch(request: Request): Promise<Response>`: Handle incoming HTTP requests
- `listFollowers(): AsyncIterableIterator<RelayFollower>`: Lists all
followers of the relay
- `getFollower(actorId: string): Promise<RelayFollower | null>`: Gets
a specific follower by actor ID

### `MastodonRelay`

A Mastodon-compatible ActivityPub relay implementation that extends `BaseRelay`.
#### Relay types

- Uses direct activity forwarding
- Immediate subscription approval
- Compatible with standard ActivityPub implementations
The relay type is specified when calling `createRelay()`:

### `LitePubRelay`

A LitePub-compatible ActivityPub relay implementation that extends `BaseRelay`.

- Uses bidirectional following
- Activities wrapped in `Announce`
- Two-phase subscription (pending → accepted)
- `"mastodon"`: Mastodon-compatible relay using direct activity forwarding,
immediate subscription approval, and LD signatures
- `"litepub"`: LitePub-compatible relay using bidirectional following,
activities wrapped in `Announce`, and two-phase subscription

### `RelayOptions`

Expand Down Expand Up @@ -304,6 +330,24 @@ type SubscriptionRequestHandler = (
- `true` to approve the subscription
- `false` to reject the subscription

### `RelayFollower`

A follower of the relay with validated Actor instance:

~~~~ typescript
interface RelayFollower {
readonly actorId: string;
readonly actor: Actor;
readonly state: "pending" | "accepted";
}
~~~~

**Properties:**

- `actorId`: The actor ID (URL) of the follower
- `actor`: The validated Actor object
- `state`: The follower's state (`"pending"` or `"accepted"`)


[JSR]: https://jsr.io/@fedify/relay
[JSR badge]: https://jsr.io/badges/@fedify/relay
Expand Down
105 changes: 102 additions & 3 deletions packages/relay/src/base.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import type { Federation, FederationBuilder } from "@fedify/fedify";
import type { RelayOptions } from "./types.ts";
import { isActor, Object as APObject } from "@fedify/fedify/vocab";
import {
isRelayFollowerData,
type Relay,
type RelayFollower,
type RelayOptions,
} from "./types.ts";

/**
* Abstract base class for relay implementations.
* Provides common infrastructure for both Mastodon and LitePub relays.
*
* @since 2.0.0
* @internal
*/
export abstract class BaseRelay {
export abstract class BaseRelay implements Relay {
protected federationBuilder: FederationBuilder<RelayOptions>;
protected options: RelayOptions;
protected federation?: Federation<RelayOptions>;
Expand All @@ -31,6 +37,99 @@ export abstract class BaseRelay {
});
}

/**
* Helper method to parse and validate follower data from storage.
* Deserializes JSON-LD actor data and validates it.
*
* @param actorId The actor ID of the follower
* @param data Raw data from KV store
* @returns RelayFollower object if valid, null otherwise
* @internal
*/
private async parseFollowerData(
actorId: string,
data: unknown,
): Promise<RelayFollower | null> {
if (!isRelayFollowerData(data)) return null;

const actor = await APObject.fromJsonLd(data.actor);
if (!isActor(actor)) return null;

return {
actorId,
actor,
state: data.state,
};
}

/**
* Lists all followers of the relay.
*
* @returns An async iterator of follower entries
*
* @example
* ```ts
* import { createRelay } from "@fedify/relay";
* import { MemoryKvStore } from "@fedify/fedify";
*
* const relay = createRelay("mastodon", {
* kv: new MemoryKvStore(),
* domain: "relay.example.com",
* subscriptionHandler: async (ctx, actor) => true,
* });
*
* for await (const follower of relay.listFollowers()) {
* console.log(`Follower: ${follower.actorId}`);
* console.log(`State: ${follower.state}`);
* console.log(`Actor: ${follower.actor.name}`);
* }
* ```
*
* @since 2.0.0
*/
async *listFollowers(): AsyncIterableIterator<RelayFollower> {
for await (const entry of this.options.kv.list(["follower"])) {
const actorId = entry.key[1];
if (typeof actorId !== "string") continue;

const follower = await this.parseFollowerData(actorId, entry.value);
if (follower) yield follower;
}
}

/**
* Gets a specific follower by actor ID.
*
* @param actorId The actor ID (URL) of the follower to retrieve
* @returns The follower entry if found, null otherwise
*
* @example
* ```ts
* import { createRelay } from "@fedify/relay";
* import { MemoryKvStore } from "@fedify/fedify";
*
* const relay = createRelay("mastodon", {
* kv: new MemoryKvStore(),
* domain: "relay.example.com",
* subscriptionHandler: async (ctx, actor) => true,
* });
*
* const follower = await relay.getFollower(
* "https://mastodon.example.com/users/alice"
* );
* if (follower) {
* console.log(`State: ${follower.state}`);
* console.log(`Actor: ${follower.actor.preferredUsername}`);
* }
* ```
*
* @since 2.0.0
*/
async getFollower(actorId: string): Promise<RelayFollower | null> {
const followerData = await this.options.kv.get(["follower", actorId]);
return await this.parseFollowerData(actorId, followerData);
}

/**
* Set up inbox listeners for handling ActivityPub activities.
* Each relay type implements this method with protocol-specific logic.
Expand Down
4 changes: 2 additions & 2 deletions packages/relay/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { Application, isActor, Object } from "@fedify/fedify/vocab";
import type { Actor } from "@fedify/fedify/vocab";
import {
isRelayFollower,
isRelayFollowerData,
RELAY_SERVER_ACTOR,
type RelayOptions,
} from "./types.ts";
Expand Down Expand Up @@ -79,7 +79,7 @@ async function getFollowerActors(
const actors: Actor[] = [];

for await (const { value } of ctx.data.kv.list(["follower"])) {
if (!isRelayFollower(value)) continue;
if (!isRelayFollowerData(value)) continue;
if (value.state !== "accepted") continue;
const actor = await Object.fromJsonLd(value.actor);
if (!isActor(actor)) continue;
Expand Down
5 changes: 2 additions & 3 deletions packages/relay/src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { BaseRelay } from "./base.ts";
import { relayBuilder } from "./builder.ts";
import { LitePubRelay } from "./litepub.ts";
import { MastodonRelay } from "./mastodon.ts";
import type { RelayOptions, RelayType } from "./types.ts";
import type { Relay, RelayOptions, RelayType } from "./types.ts";

/**
* Factory function to create a relay instance.
Expand All @@ -28,7 +27,7 @@ import type { RelayOptions, RelayType } from "./types.ts";
export function createRelay(
type: RelayType,
options: RelayOptions,
): BaseRelay {
): Relay {
switch (type) {
case "mastodon":
return new MastodonRelay(options, relayBuilder);
Expand Down
15 changes: 8 additions & 7 deletions packages/relay/src/litepub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
} from "@fedify/vocab-runtime";
import { ok, strictEqual } from "node:assert";
import test, { describe } from "node:test";
import { createRelay, isRelayFollower, type RelayOptions } from "@fedify/relay";
import { createRelay, type RelayOptions } from "@fedify/relay";
import { isRelayFollowerData } from "./types.ts";

// Simple mock document loader that returns a minimal context
const mockDocumentLoader = async (url: string): Promise<RemoteDocument> => {
Expand Down Expand Up @@ -316,7 +317,7 @@ describe("LitePubRelay", () => {
"follower",
"https://remote.example.com/users/alice",
]);
ok(isRelayFollower(followerData));
ok(isRelayFollowerData(followerData));
strictEqual(followerData.state, "pending");
});

Expand Down Expand Up @@ -418,7 +419,7 @@ describe("LitePubRelay", () => {
"follower",
"https://remote.example.com/users/alice",
]);
ok(isRelayFollower(followerData));
ok(isRelayFollowerData(followerData));
strictEqual(followerData.state, "pending");
});

Expand Down Expand Up @@ -578,7 +579,7 @@ describe("LitePubRelay", () => {
"follower",
"https://remote.example.com/users/alice",
]);
ok(isRelayFollower(followerData));
ok(isRelayFollowerData(followerData));
strictEqual(followerData.state, "accepted");
});

Expand Down Expand Up @@ -930,7 +931,7 @@ describe("LitePubRelay", () => {
strictEqual(key.length, 2);
strictEqual(key[0], "follower");
retrievedIds.push(key[1] as string);
ok(isRelayFollower(value));
ok(isRelayFollowerData(value));
strictEqual(value.state, "accepted");
}

Expand Down Expand Up @@ -1007,7 +1008,7 @@ describe("LitePubRelay", () => {
// Verify list returns both with correct states
const followers: { id: string; state: string }[] = [];
for await (const { key, value } of kv.list(["follower"])) {
if (!isRelayFollower(value)) continue;
if (!isRelayFollowerData(value)) continue;
followers.push({
id: key[1] as string,
state: value.state,
Expand Down Expand Up @@ -1044,7 +1045,7 @@ describe("LitePubRelay", () => {
// Verify list returns complete actor data
for await (const { key, value } of kv.list(["follower"])) {
strictEqual(key[1], followerId);
ok(isRelayFollower(value));
ok(isRelayFollowerData(value));
strictEqual(value.state, "accepted");
ok(value.actor && typeof value.actor === "object");
const actor = value.actor as Record<string, unknown>;
Expand Down
4 changes: 2 additions & 2 deletions packages/relay/src/litepub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from "./follow.ts";
import {
RELAY_SERVER_ACTOR,
type RelayFollower,
type RelayFollowerData,
type RelayOptions,
} from "./types.ts";

Expand Down Expand Up @@ -68,7 +68,7 @@ export class LitePubRelay extends BaseRelay {
if (!follower || !follower.id) return;

// Litepub-specific: check if already in pending state
const existingFollow = await ctx.data.kv.get<RelayFollower>([
const existingFollow = await ctx.data.kv.get<RelayFollowerData>([
"follower",
follower.id.href,
]);
Expand Down
Loading