Skip to content

Commit 83b8fa2

Browse files
authored
Merge pull request #514 from sij411/feat/addrelayapi
Add relay follower query API and refactor types
2 parents 387ffcc + e5b254d commit 83b8fa2

File tree

9 files changed

+353
-46
lines changed

9 files changed

+353
-46
lines changed

packages/relay/README.md

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,35 @@ const relay = createRelay("mastodon", {
157157
});
158158
~~~~
159159

160+
### Managing followers
161+
162+
The relay provides methods to query and manage followers without exposing
163+
internal storage details.
164+
165+
#### Listing all followers
166+
167+
~~~~ typescript
168+
for await (const follower of relay.listFollowers()) {
169+
console.log(`Follower: ${follower.actorId}`);
170+
console.log(`State: ${follower.state}`);
171+
console.log(`Actor name: ${follower.actor.name}`);
172+
console.log(`Actor type: ${follower.actor.constructor.name}`);
173+
}
174+
~~~~
175+
176+
#### Getting a specific follower
177+
178+
~~~~ typescript
179+
const follower = await relay.getFollower("https://mastodon.example.com/users/alice");
180+
if (follower) {
181+
console.log(`Found follower in state: ${follower.state}`);
182+
console.log(`Actor username: ${follower.actor.preferredUsername}`);
183+
console.log(`Inbox: ${follower.actor.inboxId?.href}`);
184+
} else {
185+
console.log("Follower not found");
186+
}
187+
~~~~
188+
160189
### Integration with web frameworks
161190

162191
The relay's `fetch()` method returns a standard `Response` object, making it
@@ -234,39 +263,36 @@ Factory function to create a relay instance.
234263
function createRelay(
235264
type: "mastodon" | "litepub",
236265
options: RelayOptions
237-
): BaseRelay
266+
): Relay
238267
~~~~
239268

240269
**Parameters:**
241270

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

245-
**Returns:** A relay instance (`MastodonRelay` or `LitePubRelay`)
274+
**Returns:** A `Relay` instance
246275

247-
### `BaseRelay`
276+
### `Relay`
248277

249-
Abstract base class for relay implementations.
278+
Public interface for ActivityPub relay implementations.
250279

251280
#### Methods
252281

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

255-
### `MastodonRelay`
256-
257-
A Mastodon-compatible ActivityPub relay implementation that extends `BaseRelay`.
288+
#### Relay types
258289

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

263-
### `LitePubRelay`
264-
265-
A LitePub-compatible ActivityPub relay implementation that extends `BaseRelay`.
266-
267-
- Uses bidirectional following
268-
- Activities wrapped in `Announce`
269-
- Two-phase subscription (pendingaccepted)
292+
- `"mastodon"`: Mastodon-compatible relay using direct activity forwarding,
293+
immediate subscription approval, and LD signatures
294+
- `"litepub"`: LitePub-compatible relay using bidirectional following,
295+
activities wrapped in `Announce`, and two-phase subscription
270296

271297
### `RelayOptions`
272298

@@ -304,6 +330,24 @@ type SubscriptionRequestHandler = (
304330
- `true` to approve the subscription
305331
- `false` to reject the subscription
306332

333+
### `RelayFollower`
334+
335+
A follower of the relay with validated Actor instance:
336+
337+
~~~~ typescript
338+
interface RelayFollower {
339+
readonly actorId: string;
340+
readonly actor: Actor;
341+
readonly state: "pending" | "accepted";
342+
}
343+
~~~~
344+
345+
**Properties:**
346+
347+
- `actorId`: The actor ID (URL) of the follower
348+
- `actor`: The validated Actor object
349+
- `state`: The follower's state (`"pending"` or `"accepted"`)
350+
307351

308352
[JSR]: https://jsr.io/@fedify/relay
309353
[JSR badge]: https://jsr.io/badges/@fedify/relay

packages/relay/src/base.ts

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import type { Federation, FederationBuilder } from "@fedify/fedify";
2-
import type { RelayOptions } from "./types.ts";
2+
import { isActor, Object as APObject } from "@fedify/fedify/vocab";
3+
import {
4+
isRelayFollowerData,
5+
type Relay,
6+
type RelayFollower,
7+
type RelayOptions,
8+
} from "./types.ts";
39

410
/**
511
* Abstract base class for relay implementations.
612
* Provides common infrastructure for both Mastodon and LitePub relays.
713
*
8-
* @since 2.0.0
14+
* @internal
915
*/
10-
export abstract class BaseRelay {
16+
export abstract class BaseRelay implements Relay {
1117
protected federationBuilder: FederationBuilder<RelayOptions>;
1218
protected options: RelayOptions;
1319
protected federation?: Federation<RelayOptions>;
@@ -31,6 +37,99 @@ export abstract class BaseRelay {
3137
});
3238
}
3339

40+
/**
41+
* Helper method to parse and validate follower data from storage.
42+
* Deserializes JSON-LD actor data and validates it.
43+
*
44+
* @param actorId The actor ID of the follower
45+
* @param data Raw data from KV store
46+
* @returns RelayFollower object if valid, null otherwise
47+
* @internal
48+
*/
49+
private async parseFollowerData(
50+
actorId: string,
51+
data: unknown,
52+
): Promise<RelayFollower | null> {
53+
if (!isRelayFollowerData(data)) return null;
54+
55+
const actor = await APObject.fromJsonLd(data.actor);
56+
if (!isActor(actor)) return null;
57+
58+
return {
59+
actorId,
60+
actor,
61+
state: data.state,
62+
};
63+
}
64+
65+
/**
66+
* Lists all followers of the relay.
67+
*
68+
* @returns An async iterator of follower entries
69+
*
70+
* @example
71+
* ```ts
72+
* import { createRelay } from "@fedify/relay";
73+
* import { MemoryKvStore } from "@fedify/fedify";
74+
*
75+
* const relay = createRelay("mastodon", {
76+
* kv: new MemoryKvStore(),
77+
* domain: "relay.example.com",
78+
* subscriptionHandler: async (ctx, actor) => true,
79+
* });
80+
*
81+
* for await (const follower of relay.listFollowers()) {
82+
* console.log(`Follower: ${follower.actorId}`);
83+
* console.log(`State: ${follower.state}`);
84+
* console.log(`Actor: ${follower.actor.name}`);
85+
* }
86+
* ```
87+
*
88+
* @since 2.0.0
89+
*/
90+
async *listFollowers(): AsyncIterableIterator<RelayFollower> {
91+
for await (const entry of this.options.kv.list(["follower"])) {
92+
const actorId = entry.key[1];
93+
if (typeof actorId !== "string") continue;
94+
95+
const follower = await this.parseFollowerData(actorId, entry.value);
96+
if (follower) yield follower;
97+
}
98+
}
99+
100+
/**
101+
* Gets a specific follower by actor ID.
102+
*
103+
* @param actorId The actor ID (URL) of the follower to retrieve
104+
* @returns The follower entry if found, null otherwise
105+
*
106+
* @example
107+
* ```ts
108+
* import { createRelay } from "@fedify/relay";
109+
* import { MemoryKvStore } from "@fedify/fedify";
110+
*
111+
* const relay = createRelay("mastodon", {
112+
* kv: new MemoryKvStore(),
113+
* domain: "relay.example.com",
114+
* subscriptionHandler: async (ctx, actor) => true,
115+
* });
116+
*
117+
* const follower = await relay.getFollower(
118+
* "https://mastodon.example.com/users/alice"
119+
* );
120+
* if (follower) {
121+
* console.log(`State: ${follower.state}`);
122+
* console.log(`Actor: ${follower.actor.preferredUsername}`);
123+
* }
124+
* ```
125+
*
126+
* @since 2.0.0
127+
*/
128+
async getFollower(actorId: string): Promise<RelayFollower | null> {
129+
const followerData = await this.options.kv.get(["follower", actorId]);
130+
return await this.parseFollowerData(actorId, followerData);
131+
}
132+
34133
/**
35134
* Set up inbox listeners for handling ActivityPub activities.
36135
* Each relay type implements this method with protocol-specific logic.

packages/relay/src/builder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { Application, isActor, Object } from "@fedify/fedify/vocab";
1010
import type { Actor } from "@fedify/fedify/vocab";
1111
import {
12-
isRelayFollower,
12+
isRelayFollowerData,
1313
RELAY_SERVER_ACTOR,
1414
type RelayOptions,
1515
} from "./types.ts";
@@ -79,7 +79,7 @@ async function getFollowerActors(
7979
const actors: Actor[] = [];
8080

8181
for await (const { value } of ctx.data.kv.list(["follower"])) {
82-
if (!isRelayFollower(value)) continue;
82+
if (!isRelayFollowerData(value)) continue;
8383
if (value.state !== "accepted") continue;
8484
const actor = await Object.fromJsonLd(value.actor);
8585
if (!isActor(actor)) continue;

packages/relay/src/factory.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import type { BaseRelay } from "./base.ts";
21
import { relayBuilder } from "./builder.ts";
32
import { LitePubRelay } from "./litepub.ts";
43
import { MastodonRelay } from "./mastodon.ts";
5-
import type { RelayOptions, RelayType } from "./types.ts";
4+
import type { Relay, RelayOptions, RelayType } from "./types.ts";
65

76
/**
87
* Factory function to create a relay instance.
@@ -28,7 +27,7 @@ import type { RelayOptions, RelayType } from "./types.ts";
2827
export function createRelay(
2928
type: RelayType,
3029
options: RelayOptions,
31-
): BaseRelay {
30+
): Relay {
3231
switch (type) {
3332
case "mastodon":
3433
return new MastodonRelay(options, relayBuilder);

packages/relay/src/litepub.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
} from "@fedify/vocab-runtime";
2020
import { ok, strictEqual } from "node:assert";
2121
import test, { describe } from "node:test";
22-
import { createRelay, isRelayFollower, type RelayOptions } from "@fedify/relay";
22+
import { createRelay, type RelayOptions } from "@fedify/relay";
23+
import { isRelayFollowerData } from "./types.ts";
2324

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

@@ -418,7 +419,7 @@ describe("LitePubRelay", () => {
418419
"follower",
419420
"https://remote.example.com/users/alice",
420421
]);
421-
ok(isRelayFollower(followerData));
422+
ok(isRelayFollowerData(followerData));
422423
strictEqual(followerData.state, "pending");
423424
});
424425

@@ -578,7 +579,7 @@ describe("LitePubRelay", () => {
578579
"follower",
579580
"https://remote.example.com/users/alice",
580581
]);
581-
ok(isRelayFollower(followerData));
582+
ok(isRelayFollowerData(followerData));
582583
strictEqual(followerData.state, "accepted");
583584
});
584585

@@ -930,7 +931,7 @@ describe("LitePubRelay", () => {
930931
strictEqual(key.length, 2);
931932
strictEqual(key[0], "follower");
932933
retrievedIds.push(key[1] as string);
933-
ok(isRelayFollower(value));
934+
ok(isRelayFollowerData(value));
934935
strictEqual(value.state, "accepted");
935936
}
936937

@@ -1007,7 +1008,7 @@ describe("LitePubRelay", () => {
10071008
// Verify list returns both with correct states
10081009
const followers: { id: string; state: string }[] = [];
10091010
for await (const { key, value } of kv.list(["follower"])) {
1010-
if (!isRelayFollower(value)) continue;
1011+
if (!isRelayFollowerData(value)) continue;
10111012
followers.push({
10121013
id: key[1] as string,
10131014
state: value.state,
@@ -1044,7 +1045,7 @@ describe("LitePubRelay", () => {
10441045
// Verify list returns complete actor data
10451046
for await (const { key, value } of kv.list(["follower"])) {
10461047
strictEqual(key[1], followerId);
1047-
ok(isRelayFollower(value));
1048+
ok(isRelayFollowerData(value));
10481049
strictEqual(value.state, "accepted");
10491050
ok(value.actor && typeof value.actor === "object");
10501051
const actor = value.actor as Record<string, unknown>;

packages/relay/src/litepub.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from "./follow.ts";
2121
import {
2222
RELAY_SERVER_ACTOR,
23-
type RelayFollower,
23+
type RelayFollowerData,
2424
type RelayOptions,
2525
} from "./types.ts";
2626

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

7070
// Litepub-specific: check if already in pending state
71-
const existingFollow = await ctx.data.kv.get<RelayFollower>([
71+
const existingFollow = await ctx.data.kv.get<RelayFollowerData>([
7272
"follower",
7373
follower.id.href,
7474
]);

0 commit comments

Comments
 (0)