@fedify/relay 2.0.0-pr.471.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright 2024–2025 Hong Minhee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ <!-- deno-fmt-ignore-file -->
2
+
3
+ @fedify/relay: ActivityPub relay for Fedify
4
+ ============================================
5
+
6
+ [![JSR][JSR badge]][JSR]
7
+ [![npm][npm badge]][npm]
8
+ [![Follow @fedify@hollo.social][@fedify@hollo.social badge]][@fedify@hollo.social]
9
+
10
+ *This package is available since Fedify 2.0.0.*
11
+
12
+ This package provides ActivityPub relay functionality for the [Fedify]
13
+ ecosystem, enabling the creation and management of relay servers that can
14
+ forward activities between federated instances.
15
+
16
+
17
+ What is an ActivityPub relay?
18
+ ------------------------------
19
+
20
+ ActivityPub relays are infrastructure components that help small instances
21
+ participate effectively in the federated social network by acting as
22
+ intermediary servers that distribute public content without requiring
23
+ individual actor-following relationships. When an instance subscribes to
24
+ a relay, all public posts from that instance are forwarded to all other
25
+ subscribed instances, creating a shared pool of federated content.
26
+
27
+
28
+ Relay protocols
29
+ ---------------
30
+
31
+ This package supports two popular relay protocols used in the fediverse:
32
+
33
+ ### Mastodon-style relay
34
+
35
+ The Mastodon-style relay protocol uses LD signatures for activity
36
+ verification and follows the Public collection. This protocol is widely
37
+ supported by Mastodon and many other ActivityPub implementations.
38
+
39
+ Key features:
40
+
41
+ - Direct activity relaying with proper content types (`Create`, `Update`,
42
+ `Delete`, `Move`)
43
+ - LD signature verification and generation
44
+ - Follows the ActivityPub Public collection
45
+ - Simple subscription mechanism via `Follow` activities
46
+
47
+ ### LitePub-style relay
48
+
49
+ *LitePub relay support is planned for a future release.*
50
+
51
+ The LitePub-style relay protocol uses bidirectional following relationships
52
+ and wraps activities in `Announce` activities for distribution.
53
+
54
+
55
+ Installation
56
+ ------------
57
+
58
+ ::: code-group
59
+
60
+ ~~~~ sh [Deno]
61
+ deno add jsr:@fedify/relay
62
+ ~~~~
63
+
64
+ ~~~~ sh [npm]
65
+ npm add @fedify/relay
66
+ ~~~~
67
+
68
+ ~~~~ sh [pnpm]
69
+ pnpm add @fedify/relay
70
+ ~~~~
71
+
72
+ ~~~~ sh [Yarn]
73
+ yarn add @fedify/relay
74
+ ~~~~
75
+
76
+ ~~~~ sh [Bun]
77
+ bun add @fedify/relay
78
+ ~~~~
79
+
80
+ :::
81
+
82
+
83
+ Usage
84
+ -----
85
+
86
+ ### Creating a Mastodon-style relay
87
+
88
+ Here's a simple example of creating a Mastodon-compatible relay server:
89
+
90
+ ~~~~ typescript
91
+ import { MastodonRelay } from "@fedify/relay";
92
+ import { MemoryKvStore } from "@fedify/fedify";
93
+
94
+ const relay = new MastodonRelay({
95
+ kv: new MemoryKvStore(),
96
+ domain: "relay.example.com",
97
+ });
98
+
99
+ // Optional: Set a custom subscription handler to approve/reject subscriptions
100
+ relay.setSubscriptionHandler(async (ctx, actor) => {
101
+ // Implement your approval logic here
102
+ // Return true to approve, false to reject
103
+ const domain = new URL(actor.id!).hostname;
104
+ const blockedDomains = ["spam.example", "blocked.example"];
105
+ return !blockedDomains.includes(domain);
106
+ });
107
+
108
+ // Serve the relay
109
+ Deno.serve((request) => relay.fetch(request));
110
+ ~~~~
111
+
112
+ ### Subscription handling
113
+
114
+ By default, the relay automatically rejects all subscription requests.
115
+ You can customize this behavior by setting a subscription handler:
116
+
117
+ ~~~~ typescript
118
+ relay.setSubscriptionHandler(async (ctx, actor) => {
119
+ // Example: Only allow subscriptions from specific domains
120
+ const domain = new URL(actor.id!).hostname;
121
+ const allowedDomains = ["mastodon.social", "fosstodon.org"];
122
+ return allowedDomains.includes(domain);
123
+ });
124
+ ~~~~
125
+
126
+ ### Integration with web frameworks
127
+
128
+ The relay's `fetch()` method returns a standard `Response` object, making it
129
+ compatible with any web framework that supports the Fetch API. Here's an
130
+ example with Hono:
131
+
132
+ ~~~~ typescript
133
+ import { Hono } from "hono";
134
+ import { MastodonRelay } from "@fedify/relay";
135
+ import { MemoryKvStore } from "@fedify/fedify";
136
+
137
+ const app = new Hono();
138
+ const relay = new MastodonRelay({
139
+ kv: new MemoryKvStore(),
140
+ domain: "relay.example.com",
141
+ });
142
+
143
+ app.use("*", async (c) => {
144
+ return await relay.fetch(c.req.raw);
145
+ });
146
+
147
+ export default app;
148
+ ~~~~
149
+
150
+
151
+ How it works
152
+ ------------
153
+
154
+ The relay operates by:
155
+
156
+ 1. **Actor registration**: The relay presents itself as a Service actor at
157
+ `/users/relay`
158
+ 2. **Subscription**: Instances subscribe to the relay by sending a `Follow`
159
+ activity
160
+ 3. **Approval**: The relay's subscription handler determines whether to
161
+ approve the subscription (responds with `Accept` or `Reject`)
162
+ 4. **Forwarding**: When a subscribed instance sends activities (`Create`,
163
+ `Update`, `Delete`, `Move`) to the relay's inbox, the relay forwards them
164
+ to all other subscribed instances
165
+ 5. **Unsubscription**: Instances can unsubscribe by sending an `Undo` activity
166
+ wrapping their original `Follow` activity
167
+
168
+
169
+ Storage requirements
170
+ --------------------
171
+
172
+ The relay requires a key–value store to persist:
173
+
174
+ - Subscriber list and their Follow activity IDs
175
+ - Subscriber actor information
176
+ - Relay's cryptographic key pairs (RSA and Ed25519)
177
+
178
+ Any `KvStore` implementation from Fedify can be used, including:
179
+
180
+ - `MemoryKvStore` (for development/testing)
181
+ - `DenoKvStore` (Deno KV)
182
+ - `RedisKvStore` (Redis)
183
+ - `PostgresKvStore` (PostgreSQL)
184
+ - `SqliteKvStore` (SQLite)
185
+
186
+ For production use, choose a persistent storage backend like Redis or
187
+ PostgreSQL. See the [Fedify documentation on key–value stores] for more
188
+ details.
189
+
190
+
191
+ API reference
192
+ -------------
193
+
194
+ ### `MastodonRelay`
195
+
196
+ A Mastodon-compatible ActivityPub relay implementation.
197
+
198
+ #### Constructor
199
+
200
+ ~~~~ typescript
201
+ new MastodonRelay(options: RelayOptions)
202
+ ~~~~
203
+
204
+ #### Properties
205
+
206
+ - `domain`: The relay's domain name (read-only)
207
+
208
+ #### Methods
209
+
210
+ - `fetch(request: Request): Promise<Response>`: Handle incoming HTTP requests
211
+ - `setSubscriptionHandler(handler: SubscriptionRequestHandler): this`:
212
+ Set a custom handler for subscription approval/rejection
213
+
214
+ ### `RelayOptions`
215
+
216
+ Configuration options for the relay:
217
+
218
+ - `kv: KvStore` (required): Key–value store for persisting relay data
219
+ - `domain?: string`: Relay's domain name (defaults to `"localhost"`)
220
+ - `documentLoaderFactory?: DocumentLoaderFactory`: Custom document loader
221
+ factory
222
+ - `authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory`:
223
+ Custom authenticated document loader factory
224
+ - `federation?: Federation<void>`: Custom Federation instance (for advanced
225
+ use cases)
226
+ - `queue?: MessageQueue`: Message queue for background activity processing
227
+
228
+ ### `SubscriptionRequestHandler`
229
+
230
+ A function that determines whether to approve a subscription request:
231
+
232
+ ~~~~ typescript
233
+ type SubscriptionRequestHandler = (
234
+ ctx: Context<void>,
235
+ clientActor: Actor,
236
+ ) => Promise<boolean>
237
+ ~~~~
238
+
239
+ Parameters:
240
+
241
+ - `ctx`: The Fedify context object
242
+ - `clientActor`: The actor requesting to subscribe
243
+
244
+ Returns:
245
+
246
+ - `true` to approve the subscription
247
+ - `false` to reject the subscription
248
+
249
+
250
+ [JSR]: https://jsr.io/@fedify/relay
251
+ [JSR badge]: https://jsr.io/badges/@fedify/relay
252
+ [npm]: https://www.npmjs.com/package/@fedify/relay
253
+ [npm badge]: https://img.shields.io/npm/v/@fedify/relay?logo=npm
254
+ [@fedify@hollo.social badge]: https://fedi-badge.deno.dev/@fedify@hollo.social/followers.svg
255
+ [@fedify@hollo.social]: https://hollo.social/@fedify
256
+ [Fedify]: https://fedify.dev/
257
+ [Fedify documentation on key–value stores]: https://fedify.dev/manual/kv
package/dist/mod.cjs ADDED
@@ -0,0 +1,229 @@
1
+ //#region rolldown:runtime
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+
23
+ //#endregion
24
+ const __fedify_fedify = __toESM(require("@fedify/fedify"));
25
+ const __fedify_fedify_vocab = __toESM(require("@fedify/fedify/vocab"));
26
+
27
+ //#region src/relay.ts
28
+ const RELAY_SERVER_ACTOR = "relay";
29
+ /**
30
+ * A Mastodon-compatible ActivityPub relay implementation.
31
+ * This relay follows Mastodon's relay protocol for maximum compatibility
32
+ * with Mastodon instances.
33
+ *
34
+ * @since 2.0.0
35
+ */
36
+ var MastodonRelay = class {
37
+ #federation;
38
+ #options;
39
+ #subscriptionHandler;
40
+ constructor(options) {
41
+ this.#options = options;
42
+ this.#federation = options.federation ?? (0, __fedify_fedify.createFederation)({
43
+ kv: options.kv,
44
+ queue: options.queue,
45
+ documentLoaderFactory: options.documentLoaderFactory,
46
+ authenticatedDocumentLoaderFactory: options.authenticatedDocumentLoaderFactory
47
+ });
48
+ this.#federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
49
+ if (identifier !== RELAY_SERVER_ACTOR) return null;
50
+ const keys = await ctx.getActorKeyPairs(identifier);
51
+ return new __fedify_fedify_vocab.Service({
52
+ id: ctx.getActorUri(identifier),
53
+ preferredUsername: identifier,
54
+ name: "ActivityPub Relay",
55
+ summary: "Mastodon-compatible ActivityPub relay server",
56
+ inbox: ctx.getInboxUri(),
57
+ followers: ctx.getFollowersUri(identifier),
58
+ url: ctx.getActorUri(identifier),
59
+ publicKey: keys[0].cryptographicKey,
60
+ assertionMethods: keys.map((k) => k.multikey)
61
+ });
62
+ }).setKeyPairsDispatcher(async (_ctx, identifier) => {
63
+ if (identifier !== RELAY_SERVER_ACTOR) return [];
64
+ const rsaPairJson = await options.kv.get([
65
+ "keypair",
66
+ "rsa",
67
+ identifier
68
+ ]);
69
+ const ed25519PairJson = await options.kv.get([
70
+ "keypair",
71
+ "ed25519",
72
+ identifier
73
+ ]);
74
+ if (rsaPairJson == null || ed25519PairJson == null) {
75
+ const rsaPair$1 = await (0, __fedify_fedify.generateCryptoKeyPair)("RSASSA-PKCS1-v1_5");
76
+ const ed25519Pair$1 = await (0, __fedify_fedify.generateCryptoKeyPair)("Ed25519");
77
+ await options.kv.set([
78
+ "keypair",
79
+ "rsa",
80
+ identifier
81
+ ], {
82
+ privateKey: await (0, __fedify_fedify.exportJwk)(rsaPair$1.privateKey),
83
+ publicKey: await (0, __fedify_fedify.exportJwk)(rsaPair$1.publicKey)
84
+ });
85
+ await options.kv.set([
86
+ "keypair",
87
+ "ed25519",
88
+ identifier
89
+ ], {
90
+ privateKey: await (0, __fedify_fedify.exportJwk)(ed25519Pair$1.privateKey),
91
+ publicKey: await (0, __fedify_fedify.exportJwk)(ed25519Pair$1.publicKey)
92
+ });
93
+ return [rsaPair$1, ed25519Pair$1];
94
+ }
95
+ const rsaPair = {
96
+ privateKey: await (0, __fedify_fedify.importJwk)(rsaPairJson.privateKey, "private"),
97
+ publicKey: await (0, __fedify_fedify.importJwk)(rsaPairJson.publicKey, "public")
98
+ };
99
+ const ed25519Pair = {
100
+ privateKey: await (0, __fedify_fedify.importJwk)(ed25519PairJson.privateKey, "private"),
101
+ publicKey: await (0, __fedify_fedify.importJwk)(ed25519PairJson.publicKey, "public")
102
+ };
103
+ return [rsaPair, ed25519Pair];
104
+ });
105
+ this.#federation.setFollowersDispatcher("/users/{identifier}/followers", async (_ctx, identifier) => {
106
+ if (identifier !== RELAY_SERVER_ACTOR) return null;
107
+ const activityIds = await options.kv.get(["followers"]) ?? [];
108
+ const actors = [];
109
+ for (const activityId of activityIds) {
110
+ const actorJson = await options.kv.get(["follower", activityId]);
111
+ const actor = await __fedify_fedify_vocab.Object.fromJsonLd(actorJson);
112
+ if (!(0, __fedify_fedify_vocab.isActor)(actor)) continue;
113
+ actors.push(actor);
114
+ }
115
+ return { items: actors };
116
+ });
117
+ this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(__fedify_fedify_vocab.Follow, async (ctx, follow) => {
118
+ if (follow.id == null || follow.objectId == null) return;
119
+ const parsed = ctx.parseUri(follow.objectId);
120
+ const isPublicFollow = follow.objectId.href === "https://www.w3.org/ns/activitystreams#Public";
121
+ if (!isPublicFollow && parsed?.type !== "actor") return;
122
+ const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR);
123
+ const recipient = await follow.getActor(ctx);
124
+ if (recipient == null || recipient.id == null || recipient.preferredUsername == null || recipient.inboxId == null) return;
125
+ let approved = false;
126
+ if (this.#subscriptionHandler) approved = await this.#subscriptionHandler(ctx, recipient);
127
+ if (approved) {
128
+ const followers = await options.kv.get(["followers"]) ?? [];
129
+ followers.push(follow.id.href);
130
+ await options.kv.set(["followers"], followers);
131
+ await options.kv.set(["follower", follow.id.href], await recipient.toJsonLd());
132
+ await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, recipient, new __fedify_fedify_vocab.Accept({
133
+ id: new URL(`#accepts`, relayActorUri),
134
+ actor: relayActorUri,
135
+ object: follow
136
+ }));
137
+ } else await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, recipient, new __fedify_fedify_vocab.Reject({
138
+ id: new URL(`#rejects`, relayActorUri),
139
+ actor: relayActorUri,
140
+ object: follow
141
+ }));
142
+ }).on(__fedify_fedify_vocab.Undo, async (ctx, undo) => {
143
+ const activity = await undo.getObject(ctx);
144
+ if (activity instanceof __fedify_fedify_vocab.Follow) {
145
+ if (activity.id == null || activity.actorId == null) return;
146
+ const activityId = activity.id.href;
147
+ const followers = await options.kv.get(["followers"]) ?? [];
148
+ const updatedFollowers = followers.filter((id) => id !== activityId);
149
+ await options.kv.set(["followers"], updatedFollowers);
150
+ options.kv.delete(["follower", activityId]);
151
+ } else console.warn("Unsupported object type ({type}) for Undo activity: {object}", {
152
+ type: activity?.constructor.name,
153
+ object: activity
154
+ });
155
+ }).on(__fedify_fedify_vocab.Create, async (ctx, create) => {
156
+ const sender = await create.getActor(ctx);
157
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
158
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
159
+ skipIfUnsigned: true,
160
+ excludeBaseUris,
161
+ preferSharedInbox: true
162
+ });
163
+ }).on(__fedify_fedify_vocab.Delete, async (ctx, deleteActivity) => {
164
+ const sender = await deleteActivity.getActor(ctx);
165
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
166
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
167
+ skipIfUnsigned: true,
168
+ excludeBaseUris,
169
+ preferSharedInbox: true
170
+ });
171
+ }).on(__fedify_fedify_vocab.Move, async (ctx, deleteActivity) => {
172
+ const sender = await deleteActivity.getActor(ctx);
173
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
174
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
175
+ skipIfUnsigned: true,
176
+ excludeBaseUris,
177
+ preferSharedInbox: true
178
+ });
179
+ }).on(__fedify_fedify_vocab.Update, async (ctx, deleteActivity) => {
180
+ const sender = await deleteActivity.getActor(ctx);
181
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
182
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
183
+ skipIfUnsigned: true,
184
+ excludeBaseUris,
185
+ preferSharedInbox: true
186
+ });
187
+ });
188
+ }
189
+ get domain() {
190
+ return this.#options.domain || "localhost";
191
+ }
192
+ fetch(request) {
193
+ return this.#federation.fetch(request, { contextData: void 0 });
194
+ }
195
+ setSubscriptionHandler(handler) {
196
+ this.#subscriptionHandler = handler;
197
+ return this;
198
+ }
199
+ };
200
+ /**
201
+ * A LitePub-compatible ActivityPub relay implementation.
202
+ * This relay follows LitePub's relay protocol and extensions for
203
+ * enhanced federation capabilities.
204
+ *
205
+ * @since 2.0.0
206
+ */
207
+ var LitePubRelay = class {
208
+ #federation;
209
+ #options;
210
+ #subscriptionHandler;
211
+ constructor(options) {
212
+ this.#options = options;
213
+ this.#federation = (0, __fedify_fedify.createFederation)({ kv: options.kv });
214
+ }
215
+ get domain() {
216
+ return this.#options.domain || "localhost";
217
+ }
218
+ fetch(request) {
219
+ return this.#federation.fetch(request, { contextData: void 0 });
220
+ }
221
+ setSubscriptionHandler(handler) {
222
+ this.#subscriptionHandler = handler;
223
+ return this;
224
+ }
225
+ };
226
+
227
+ //#endregion
228
+ exports.LitePubRelay = LitePubRelay;
229
+ exports.MastodonRelay = MastodonRelay;
package/dist/mod.d.cts ADDED
@@ -0,0 +1,59 @@
1
+ import { Context, Federation, KvStore, MessageQueue } from "@fedify/fedify";
2
+ import { Actor } from "@fedify/fedify/vocab";
3
+ import { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory } from "@fedify/vocab-runtime";
4
+
5
+ //#region src/relay.d.ts
6
+
7
+ /**
8
+ * Handler for subscription requests (Follow/Undo activities).
9
+ */
10
+ type SubscriptionRequestHandler = (ctx: Context<void>, clientActor: Actor) => Promise<boolean>;
11
+ /**
12
+ * Configuration options for the ActivityPub relay.
13
+ */
14
+ interface RelayOptions {
15
+ kv: KvStore;
16
+ domain?: string;
17
+ documentLoaderFactory?: DocumentLoaderFactory;
18
+ authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory;
19
+ federation?: Federation<void>;
20
+ queue?: MessageQueue;
21
+ }
22
+ /**
23
+ * Base interface for ActivityPub relay implementations.
24
+ */
25
+ interface Relay {
26
+ readonly domain: string;
27
+ fetch(request: Request): Promise<Response>;
28
+ setSubscriptionHandler(handler: SubscriptionRequestHandler): this;
29
+ }
30
+ /**
31
+ * A Mastodon-compatible ActivityPub relay implementation.
32
+ * This relay follows Mastodon's relay protocol for maximum compatibility
33
+ * with Mastodon instances.
34
+ *
35
+ * @since 2.0.0
36
+ */
37
+ declare class MastodonRelay implements Relay {
38
+ #private;
39
+ constructor(options: RelayOptions);
40
+ get domain(): string;
41
+ fetch(request: Request): Promise<Response>;
42
+ setSubscriptionHandler(handler: SubscriptionRequestHandler): this;
43
+ }
44
+ /**
45
+ * A LitePub-compatible ActivityPub relay implementation.
46
+ * This relay follows LitePub's relay protocol and extensions for
47
+ * enhanced federation capabilities.
48
+ *
49
+ * @since 2.0.0
50
+ */
51
+ declare class LitePubRelay implements Relay {
52
+ #private;
53
+ constructor(options: RelayOptions);
54
+ get domain(): string;
55
+ fetch(request: Request): Promise<Response>;
56
+ setSubscriptionHandler(handler: SubscriptionRequestHandler): this;
57
+ }
58
+ //#endregion
59
+ export { LitePubRelay, MastodonRelay, Relay, RelayOptions };
package/dist/mod.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { Context, Federation, KvStore, MessageQueue } from "@fedify/fedify";
2
+ import { Actor } from "@fedify/fedify/vocab";
3
+ import { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory } from "@fedify/vocab-runtime";
4
+
5
+ //#region src/relay.d.ts
6
+
7
+ /**
8
+ * Handler for subscription requests (Follow/Undo activities).
9
+ */
10
+ type SubscriptionRequestHandler = (ctx: Context<void>, clientActor: Actor) => Promise<boolean>;
11
+ /**
12
+ * Configuration options for the ActivityPub relay.
13
+ */
14
+ interface RelayOptions {
15
+ kv: KvStore;
16
+ domain?: string;
17
+ documentLoaderFactory?: DocumentLoaderFactory;
18
+ authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory;
19
+ federation?: Federation<void>;
20
+ queue?: MessageQueue;
21
+ }
22
+ /**
23
+ * Base interface for ActivityPub relay implementations.
24
+ */
25
+ interface Relay {
26
+ readonly domain: string;
27
+ fetch(request: Request): Promise<Response>;
28
+ setSubscriptionHandler(handler: SubscriptionRequestHandler): this;
29
+ }
30
+ /**
31
+ * A Mastodon-compatible ActivityPub relay implementation.
32
+ * This relay follows Mastodon's relay protocol for maximum compatibility
33
+ * with Mastodon instances.
34
+ *
35
+ * @since 2.0.0
36
+ */
37
+ declare class MastodonRelay implements Relay {
38
+ #private;
39
+ constructor(options: RelayOptions);
40
+ get domain(): string;
41
+ fetch(request: Request): Promise<Response>;
42
+ setSubscriptionHandler(handler: SubscriptionRequestHandler): this;
43
+ }
44
+ /**
45
+ * A LitePub-compatible ActivityPub relay implementation.
46
+ * This relay follows LitePub's relay protocol and extensions for
47
+ * enhanced federation capabilities.
48
+ *
49
+ * @since 2.0.0
50
+ */
51
+ declare class LitePubRelay implements Relay {
52
+ #private;
53
+ constructor(options: RelayOptions);
54
+ get domain(): string;
55
+ fetch(request: Request): Promise<Response>;
56
+ setSubscriptionHandler(handler: SubscriptionRequestHandler): this;
57
+ }
58
+ //#endregion
59
+ export { LitePubRelay, MastodonRelay, Relay, RelayOptions };
package/dist/mod.js ADDED
@@ -0,0 +1,205 @@
1
+ import { createFederation, exportJwk, generateCryptoKeyPair, importJwk } from "@fedify/fedify";
2
+ import { Accept, Create, Delete, Follow, Move, Object as Object$1, Reject, Service, Undo, Update, isActor } from "@fedify/fedify/vocab";
3
+
4
+ //#region src/relay.ts
5
+ const RELAY_SERVER_ACTOR = "relay";
6
+ /**
7
+ * A Mastodon-compatible ActivityPub relay implementation.
8
+ * This relay follows Mastodon's relay protocol for maximum compatibility
9
+ * with Mastodon instances.
10
+ *
11
+ * @since 2.0.0
12
+ */
13
+ var MastodonRelay = class {
14
+ #federation;
15
+ #options;
16
+ #subscriptionHandler;
17
+ constructor(options) {
18
+ this.#options = options;
19
+ this.#federation = options.federation ?? createFederation({
20
+ kv: options.kv,
21
+ queue: options.queue,
22
+ documentLoaderFactory: options.documentLoaderFactory,
23
+ authenticatedDocumentLoaderFactory: options.authenticatedDocumentLoaderFactory
24
+ });
25
+ this.#federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
26
+ if (identifier !== RELAY_SERVER_ACTOR) return null;
27
+ const keys = await ctx.getActorKeyPairs(identifier);
28
+ return new Service({
29
+ id: ctx.getActorUri(identifier),
30
+ preferredUsername: identifier,
31
+ name: "ActivityPub Relay",
32
+ summary: "Mastodon-compatible ActivityPub relay server",
33
+ inbox: ctx.getInboxUri(),
34
+ followers: ctx.getFollowersUri(identifier),
35
+ url: ctx.getActorUri(identifier),
36
+ publicKey: keys[0].cryptographicKey,
37
+ assertionMethods: keys.map((k) => k.multikey)
38
+ });
39
+ }).setKeyPairsDispatcher(async (_ctx, identifier) => {
40
+ if (identifier !== RELAY_SERVER_ACTOR) return [];
41
+ const rsaPairJson = await options.kv.get([
42
+ "keypair",
43
+ "rsa",
44
+ identifier
45
+ ]);
46
+ const ed25519PairJson = await options.kv.get([
47
+ "keypair",
48
+ "ed25519",
49
+ identifier
50
+ ]);
51
+ if (rsaPairJson == null || ed25519PairJson == null) {
52
+ const rsaPair$1 = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5");
53
+ const ed25519Pair$1 = await generateCryptoKeyPair("Ed25519");
54
+ await options.kv.set([
55
+ "keypair",
56
+ "rsa",
57
+ identifier
58
+ ], {
59
+ privateKey: await exportJwk(rsaPair$1.privateKey),
60
+ publicKey: await exportJwk(rsaPair$1.publicKey)
61
+ });
62
+ await options.kv.set([
63
+ "keypair",
64
+ "ed25519",
65
+ identifier
66
+ ], {
67
+ privateKey: await exportJwk(ed25519Pair$1.privateKey),
68
+ publicKey: await exportJwk(ed25519Pair$1.publicKey)
69
+ });
70
+ return [rsaPair$1, ed25519Pair$1];
71
+ }
72
+ const rsaPair = {
73
+ privateKey: await importJwk(rsaPairJson.privateKey, "private"),
74
+ publicKey: await importJwk(rsaPairJson.publicKey, "public")
75
+ };
76
+ const ed25519Pair = {
77
+ privateKey: await importJwk(ed25519PairJson.privateKey, "private"),
78
+ publicKey: await importJwk(ed25519PairJson.publicKey, "public")
79
+ };
80
+ return [rsaPair, ed25519Pair];
81
+ });
82
+ this.#federation.setFollowersDispatcher("/users/{identifier}/followers", async (_ctx, identifier) => {
83
+ if (identifier !== RELAY_SERVER_ACTOR) return null;
84
+ const activityIds = await options.kv.get(["followers"]) ?? [];
85
+ const actors = [];
86
+ for (const activityId of activityIds) {
87
+ const actorJson = await options.kv.get(["follower", activityId]);
88
+ const actor = await Object$1.fromJsonLd(actorJson);
89
+ if (!isActor(actor)) continue;
90
+ actors.push(actor);
91
+ }
92
+ return { items: actors };
93
+ });
94
+ this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Follow, async (ctx, follow) => {
95
+ if (follow.id == null || follow.objectId == null) return;
96
+ const parsed = ctx.parseUri(follow.objectId);
97
+ const isPublicFollow = follow.objectId.href === "https://www.w3.org/ns/activitystreams#Public";
98
+ if (!isPublicFollow && parsed?.type !== "actor") return;
99
+ const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR);
100
+ const recipient = await follow.getActor(ctx);
101
+ if (recipient == null || recipient.id == null || recipient.preferredUsername == null || recipient.inboxId == null) return;
102
+ let approved = false;
103
+ if (this.#subscriptionHandler) approved = await this.#subscriptionHandler(ctx, recipient);
104
+ if (approved) {
105
+ const followers = await options.kv.get(["followers"]) ?? [];
106
+ followers.push(follow.id.href);
107
+ await options.kv.set(["followers"], followers);
108
+ await options.kv.set(["follower", follow.id.href], await recipient.toJsonLd());
109
+ await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, recipient, new Accept({
110
+ id: new URL(`#accepts`, relayActorUri),
111
+ actor: relayActorUri,
112
+ object: follow
113
+ }));
114
+ } else await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, recipient, new Reject({
115
+ id: new URL(`#rejects`, relayActorUri),
116
+ actor: relayActorUri,
117
+ object: follow
118
+ }));
119
+ }).on(Undo, async (ctx, undo) => {
120
+ const activity = await undo.getObject(ctx);
121
+ if (activity instanceof Follow) {
122
+ if (activity.id == null || activity.actorId == null) return;
123
+ const activityId = activity.id.href;
124
+ const followers = await options.kv.get(["followers"]) ?? [];
125
+ const updatedFollowers = followers.filter((id) => id !== activityId);
126
+ await options.kv.set(["followers"], updatedFollowers);
127
+ options.kv.delete(["follower", activityId]);
128
+ } else console.warn("Unsupported object type ({type}) for Undo activity: {object}", {
129
+ type: activity?.constructor.name,
130
+ object: activity
131
+ });
132
+ }).on(Create, async (ctx, create) => {
133
+ const sender = await create.getActor(ctx);
134
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
135
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
136
+ skipIfUnsigned: true,
137
+ excludeBaseUris,
138
+ preferSharedInbox: true
139
+ });
140
+ }).on(Delete, async (ctx, deleteActivity) => {
141
+ const sender = await deleteActivity.getActor(ctx);
142
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
143
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
144
+ skipIfUnsigned: true,
145
+ excludeBaseUris,
146
+ preferSharedInbox: true
147
+ });
148
+ }).on(Move, async (ctx, deleteActivity) => {
149
+ const sender = await deleteActivity.getActor(ctx);
150
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
151
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
152
+ skipIfUnsigned: true,
153
+ excludeBaseUris,
154
+ preferSharedInbox: true
155
+ });
156
+ }).on(Update, async (ctx, deleteActivity) => {
157
+ const sender = await deleteActivity.getActor(ctx);
158
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
159
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
160
+ skipIfUnsigned: true,
161
+ excludeBaseUris,
162
+ preferSharedInbox: true
163
+ });
164
+ });
165
+ }
166
+ get domain() {
167
+ return this.#options.domain || "localhost";
168
+ }
169
+ fetch(request) {
170
+ return this.#federation.fetch(request, { contextData: void 0 });
171
+ }
172
+ setSubscriptionHandler(handler) {
173
+ this.#subscriptionHandler = handler;
174
+ return this;
175
+ }
176
+ };
177
+ /**
178
+ * A LitePub-compatible ActivityPub relay implementation.
179
+ * This relay follows LitePub's relay protocol and extensions for
180
+ * enhanced federation capabilities.
181
+ *
182
+ * @since 2.0.0
183
+ */
184
+ var LitePubRelay = class {
185
+ #federation;
186
+ #options;
187
+ #subscriptionHandler;
188
+ constructor(options) {
189
+ this.#options = options;
190
+ this.#federation = createFederation({ kv: options.kv });
191
+ }
192
+ get domain() {
193
+ return this.#options.domain || "localhost";
194
+ }
195
+ fetch(request) {
196
+ return this.#federation.fetch(request, { contextData: void 0 });
197
+ }
198
+ setSubscriptionHandler(handler) {
199
+ this.#subscriptionHandler = handler;
200
+ return this;
201
+ }
202
+ };
203
+
204
+ //#endregion
205
+ export { LitePubRelay, MastodonRelay };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@fedify/relay",
3
+ "version": "2.0.0-pr.471.1",
4
+ "description": "ActivityPub relay support for Fedify",
5
+ "keywords": [
6
+ "Fedify",
7
+ "ActivityPub",
8
+ "Fediverse",
9
+ "Relay"
10
+ ],
11
+ "author": {
12
+ "name": "Jiwon Kwon",
13
+ "email": "jiwonkwon@duck.com"
14
+ },
15
+ "homepage": "https://fedify.dev/",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/fedify-dev/fedify.git",
19
+ "directory": "packages/relay"
20
+ },
21
+ "license": "MIT",
22
+ "bugs": {
23
+ "url": "https://github.com/fedify-dev/fedify/issues"
24
+ },
25
+ "funding": [
26
+ "https://opencollective.com/fedify",
27
+ "https://github.com/sponsors/dahlia"
28
+ ],
29
+ "type": "module",
30
+ "main": "./dist/mod.cjs",
31
+ "module": "./dist/mod.js",
32
+ "types": "./dist/mod.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": {
36
+ "import": "./dist/mod.d.ts",
37
+ "require": "./dist/mod.d.cts",
38
+ "default": "./dist/mod.d.ts"
39
+ },
40
+ "import": "./dist/mod.js",
41
+ "require": "./dist/mod.cjs",
42
+ "default": "./dist/mod.js"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist/",
48
+ "package.json"
49
+ ],
50
+ "peerDependencies": {
51
+ "@fedify/fedify": "^2.0.0-pr.471.1"
52
+ },
53
+ "devDependencies": {
54
+ "tsdown": "^0.12.9",
55
+ "typescript": "^5.9.3"
56
+ },
57
+ "scripts": {
58
+ "build": "deno task codegen && tsdown",
59
+ "prepublish": "deno task codegen && tsdown",
60
+ "test": "deno task codegen && tsdown && cd dist/ && node --test",
61
+ "test:bun": "deno task codegen && deno task pnpm:build-vocab && tsdown && cd dist/ && bun test --timeout 60000",
62
+ "test:cfworkers": "deno task codegen && wrangler deploy --dry-run --outdir src/cfworkers && node --import=tsx src/cfworkers/client.ts"
63
+ }
64
+ }