@rmdes/indiekit-endpoint-activitypub 2.1.1 → 2.2.0

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/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import express from "express";
2
2
 
3
3
  import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
4
+ import { initRedisCache } from "./lib/redis-cache.js";
4
5
  import {
5
6
  createFedifyMiddleware,
6
7
  } from "./lib/federation-bridge.js";
@@ -1059,6 +1060,11 @@ export default class ActivityPubEndpoint {
1059
1060
  console.warn("[ActivityPub] Profile seed failed:", error.message);
1060
1061
  });
1061
1062
 
1063
+ // Initialize Redis cache for plugin-level KV (fedidb, batch-refollow, etc.)
1064
+ if (this.options.redisUrl) {
1065
+ initRedisCache(this.options.redisUrl);
1066
+ }
1067
+
1062
1068
  // Set up Fedify Federation instance
1063
1069
  const { federation } = setupFederation({
1064
1070
  collections: this._collections,
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { Follow } from "@fedify/fedify/vocab";
15
15
  import { logActivity } from "./activity-log.js";
16
+ import { cacheGet, cacheSet } from "./redis-cache.js";
16
17
 
17
18
  const BATCH_SIZE = 10;
18
19
  const DELAY_PER_FOLLOW = 3_000;
@@ -58,7 +59,7 @@ export async function startBatchRefollow(options) {
58
59
  );
59
60
 
60
61
  // Set job state to running
61
- await setJobState(collections, "running");
62
+ await setJobState("running");
62
63
 
63
64
  // Schedule first batch after startup delay
64
65
  _timer = setTimeout(() => processNextBatch(options), STARTUP_DELAY);
@@ -81,7 +82,7 @@ export async function pauseBatchRefollow(collections) {
81
82
  { $set: { source: "import" } },
82
83
  );
83
84
 
84
- await setJobState(collections, "paused");
85
+ await setJobState("paused");
85
86
  console.info("[ActivityPub] Batch refollow: paused");
86
87
  }
87
88
 
@@ -100,7 +101,7 @@ export async function resumeBatchRefollow(options) {
100
101
  _timer = null;
101
102
  }
102
103
 
103
- await setJobState(options.collections, "running");
104
+ await setJobState("running");
104
105
  _timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES);
105
106
  console.info("[ActivityPub] Batch refollow: resumed");
106
107
  }
@@ -112,8 +113,8 @@ export async function resumeBatchRefollow(options) {
112
113
  * @returns {Promise<object>} Status object
113
114
  */
114
115
  export async function getBatchRefollowStatus(collections) {
115
- const state = await collections.ap_kv.findOne({ _id: KV_KEY });
116
- const status = state?.value?.status || "idle";
116
+ const state = await cacheGet(KV_KEY);
117
+ const status = state?.status || "idle";
117
118
 
118
119
  const [remaining, sent, failed, federated] = await Promise.all([
119
120
  collections.ap_following.countDocuments({ source: "import" }),
@@ -138,8 +139,8 @@ export async function getBatchRefollowStatus(collections) {
138
139
  federated,
139
140
  completed,
140
141
  progressPercent,
141
- startedAt: state?.value?.startedAt || null,
142
- updatedAt: state?.value?.updatedAt || null,
142
+ startedAt: state?.startedAt || null,
143
+ updatedAt: state?.updatedAt || null,
143
144
  };
144
145
  }
145
146
 
@@ -152,8 +153,8 @@ async function processNextBatch(options) {
152
153
  const { federation, collections, handle, publicationUrl } = options;
153
154
  _timer = null;
154
155
 
155
- const state = await collections.ap_kv.findOne({ _id: KV_KEY });
156
- if (state?.value?.status !== "running") return;
156
+ const state = await cacheGet(KV_KEY);
157
+ if (state?.status !== "running") return;
157
158
 
158
159
  // Claim a batch atomically: set source to "refollow:pending"
159
160
  const entries = [];
@@ -196,7 +197,7 @@ async function processNextBatch(options) {
196
197
  );
197
198
  }
198
199
 
199
- await setJobState(collections, "completed");
200
+ await setJobState("completed");
200
201
  console.info("[ActivityPub] Batch refollow: completed");
201
202
  return;
202
203
  }
@@ -212,7 +213,7 @@ async function processNextBatch(options) {
212
213
  }
213
214
 
214
215
  // Update job state timestamp
215
- await setJobState(collections, "running");
216
+ await setJobState("running");
216
217
 
217
218
  // Schedule next batch
218
219
  _timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES);
@@ -306,25 +307,24 @@ async function processOneFollow(options, entry) {
306
307
  }
307
308
 
308
309
  /**
309
- * Set the batch re-follow job state in ap_kv.
310
+ * Set the batch re-follow job state in Redis.
310
311
  */
311
- async function setJobState(collections, status) {
312
+ async function setJobState(status) {
312
313
  const now = new Date().toISOString();
313
- const update = {
314
- $set: {
315
- "value.status": status,
316
- "value.updatedAt": now,
317
- },
318
- $setOnInsert: { _id: KV_KEY },
314
+ const existing = (await cacheGet(KV_KEY)) || {};
315
+
316
+ const newState = {
317
+ ...existing,
318
+ status,
319
+ updatedAt: now,
319
320
  };
320
321
 
321
322
  // Only set startedAt on initial start or resume
322
- const existing = await collections.ap_kv.findOne({ _id: KV_KEY });
323
- if (!existing?.value?.startedAt || status === "running" && existing?.value?.status !== "running") {
324
- update.$set["value.startedAt"] = now;
323
+ if (!existing.startedAt || (status === "running" && existing.status !== "running")) {
324
+ newState.startedAt = now;
325
325
  }
326
326
 
327
- await collections.ap_kv.updateOne({ _id: KV_KEY }, update, { upsert: true });
327
+ await cacheSet(KV_KEY, newState);
328
328
  }
329
329
 
330
330
  function sleep(ms) {
@@ -40,7 +40,6 @@ export function dashboardController(mountPath) {
40
40
  // Get batch re-follow status for the progress section
41
41
  const refollowStatus = await getBatchRefollowStatus({
42
42
  ap_following: followingCollection,
43
- ap_kv: application?.collections?.get("ap_kv"),
44
43
  });
45
44
 
46
45
  response.render("activitypub-dashboard", {
@@ -233,10 +233,7 @@ export function instanceSearchApiController(mountPath) {
233
233
  return response.json([]);
234
234
  }
235
235
 
236
- const { application } = request.app.locals;
237
- const kvCollection = application?.collections?.get("ap_kv") || null;
238
-
239
- const results = await searchInstances(kvCollection, q, 8);
236
+ const results = await searchInstances(q, 8);
240
237
  response.json(results);
241
238
  } catch (error) {
242
239
  next(error);
@@ -262,10 +259,7 @@ export function instanceCheckApiController(mountPath) {
262
259
  return response.status(400).json({ supported: false, error: "Invalid domain" });
263
260
  }
264
261
 
265
- const { application } = request.app.locals;
266
- const kvCollection = application?.collections?.get("ap_kv") || null;
267
-
268
- const result = await checkInstanceTimeline(kvCollection, validated);
262
+ const result = await checkInstanceTimeline(validated);
269
263
  response.json(result);
270
264
  } catch (error) {
271
265
  next(error);
@@ -280,10 +274,7 @@ export function instanceCheckApiController(mountPath) {
280
274
  export function popularAccountsApiController(mountPath) {
281
275
  return async (request, response, next) => {
282
276
  try {
283
- const { application } = request.app.locals;
284
- const kvCollection = application?.collections?.get("ap_kv") || null;
285
-
286
- const accounts = await getPopularAccounts(kvCollection, 50);
277
+ const accounts = await getPopularAccounts(50);
287
278
  response.json(accounts);
288
279
  } catch (error) {
289
280
  next(error);
@@ -38,7 +38,11 @@ function postToCardItem(post, profile) {
38
38
  photo: profile?.icon || "",
39
39
  },
40
40
  photo,
41
- category: props.category || [],
41
+ category: Array.isArray(props.category)
42
+ ? props.category
43
+ : props.category
44
+ ? [props.category]
45
+ : [],
42
46
  };
43
47
  }
44
48
 
@@ -24,7 +24,6 @@ export function refollowPauseController(mountPath, plugin) {
24
24
  const { application } = request.app.locals;
25
25
  const collections = {
26
26
  ap_following: application.collections.get("ap_following"),
27
- ap_kv: application.collections.get("ap_kv"),
28
27
  };
29
28
 
30
29
  await pauseBatchRefollow(collections);
@@ -72,7 +71,6 @@ export function refollowStatusController(mountPath) {
72
71
  const { application } = request.app.locals;
73
72
  const collections = {
74
73
  ap_following: application.collections.get("ap_following"),
75
- ap_kv: application.collections.get("ap_kv"),
76
74
  };
77
75
 
78
76
  const status = await getBatchRefollowStatus(collections);
@@ -34,7 +34,7 @@ import {
34
34
  Service,
35
35
  } from "@fedify/fedify/vocab";
36
36
  import { configure, getConsoleSink } from "@logtape/logtape";
37
- import { RedisMessageQueue } from "@fedify/redis";
37
+ import { RedisMessageQueue, RedisKvStore } from "@fedify/redis";
38
38
  import { createFederationDebugger } from "@fedify/debugger";
39
39
  import Redis from "ioredis";
40
40
  import { MongoKvStore } from "./kv-store.js";
@@ -100,6 +100,7 @@ export function setupFederation(options) {
100
100
  }
101
101
 
102
102
  let queue;
103
+ let kv;
103
104
  if (redisUrl) {
104
105
  const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl));
105
106
  if (parallelWorkers > 1) {
@@ -111,15 +112,21 @@ export function setupFederation(options) {
111
112
  queue = redisQueue;
112
113
  console.info("[ActivityPub] Using Redis message queue (single worker)");
113
114
  }
115
+ // Use Redis for Fedify KV store — idempotence records, public key cache,
116
+ // remote document cache. Redis handles TTL natively so entries auto-expire
117
+ // instead of growing unbounded in MongoDB.
118
+ kv = new RedisKvStore(new Redis(redisUrl));
119
+ console.info("[ActivityPub] Using Redis KV store for Fedify");
114
120
  } else {
115
121
  queue = new InProcessMessageQueue();
122
+ kv = new MongoKvStore(collections.ap_kv);
116
123
  console.warn(
117
- "[ActivityPub] Using in-process message queue (not recommended for production)",
124
+ "[ActivityPub] Using in-process message queue + MongoDB KV store (not recommended for production)",
118
125
  );
119
126
  }
120
127
 
121
128
  const federation = createFederation({
122
- kv: new MongoKvStore(collections.ap_kv),
129
+ kv,
123
130
  queue,
124
131
  });
125
132
 
package/lib/fedidb.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * FediDB API client with MongoDB caching.
2
+ * FediDB API client with Redis caching.
3
3
  *
4
4
  * Wraps https://api.fedidb.org/v1/ endpoints:
5
5
  * - /servers — cursor-paginated list of known fediverse instances (ranked by size)
@@ -9,12 +9,14 @@
9
9
  * returns the same ranked list. We paginate through ~500 servers, cache the full
10
10
  * corpus for 24 hours, and filter locally when the user searches.
11
11
  *
12
- * Cache TTL: 24 hours for both datasets.
12
+ * Cache TTL: 24 hours for both datasets (enforced by Redis TTL).
13
13
  */
14
14
 
15
+ import { cacheGet, cacheSet } from "./redis-cache.js";
16
+
15
17
  const API_BASE = "https://api.fedidb.org/v1";
16
18
  const FETCH_TIMEOUT_MS = 8_000;
17
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
19
+ const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours
18
20
 
19
21
  /**
20
22
  * Fetch with timeout helper.
@@ -35,60 +37,21 @@ async function fetchWithTimeout(url) {
35
37
  }
36
38
  }
37
39
 
38
- /**
39
- * Get cached data from ap_kv, or null if expired/missing.
40
- * @param {object} kvCollection - MongoDB ap_kv collection
41
- * @param {string} cacheKey - Key to look up
42
- * @returns {Promise<object|null>} Cached data or null
43
- */
44
- async function getFromCache(kvCollection, cacheKey) {
45
- if (!kvCollection) return null;
46
- try {
47
- const doc = await kvCollection.findOne({ _id: cacheKey });
48
- if (!doc?.value?.data) return null;
49
- const age = Date.now() - (doc.value.cachedAt || 0);
50
- if (age > CACHE_TTL_MS) return null;
51
- return doc.value.data;
52
- } catch {
53
- return null;
54
- }
55
- }
56
-
57
- /**
58
- * Write data to ap_kv cache.
59
- * @param {object} kvCollection - MongoDB ap_kv collection
60
- * @param {string} cacheKey - Key to store under
61
- * @param {object} data - Data to cache
62
- */
63
- async function writeToCache(kvCollection, cacheKey, data) {
64
- if (!kvCollection) return;
65
- try {
66
- await kvCollection.updateOne(
67
- { _id: cacheKey },
68
- { $set: { value: { data, cachedAt: Date.now() } } },
69
- { upsert: true }
70
- );
71
- } catch {
72
- // Cache write failure is non-critical
73
- }
74
- }
75
-
76
40
  /**
77
41
  * Fetch the FediDB server catalogue by paginating through cursor-based results.
78
42
  * Cached for 24 hours as a single entry. The API ignores the `q` param and
79
43
  * always returns a ranked list, so we collect a large corpus and filter locally.
80
44
  *
81
45
  * Paginates up to MAX_PAGES (13 pages × 40 = ~520 servers), which covers
82
- * all well-known instances. Results are cached in ap_kv for 24 hours.
46
+ * all well-known instances. Results are cached in Redis for 24 hours.
83
47
  *
84
- * @param {object} kvCollection - MongoDB ap_kv collection
85
48
  * @returns {Promise<Array>}
86
49
  */
87
50
  const MAX_PAGES = 13;
88
51
 
89
- async function getAllServers(kvCollection) {
52
+ async function getAllServers() {
90
53
  const cacheKey = "fedidb:servers-all";
91
- const cached = await getFromCache(kvCollection, cacheKey);
54
+ const cached = await cacheGet(cacheKey);
92
55
  if (cached) return cached;
93
56
 
94
57
  const results = [];
@@ -123,7 +86,7 @@ async function getAllServers(kvCollection) {
123
86
  }
124
87
 
125
88
  if (results.length > 0) {
126
- await writeToCache(kvCollection, cacheKey, results);
89
+ await cacheSet(cacheKey, results, CACHE_TTL_SECONDS);
127
90
  }
128
91
  } catch {
129
92
  // Return whatever we collected so far
@@ -137,19 +100,16 @@ async function getAllServers(kvCollection) {
137
100
  * Returns a flat array of { domain, software, description, mau, openRegistration }.
138
101
  *
139
102
  * Fetches the full server list once (cached 24h) and filters by domain/software match.
140
- * FediDB's /v1/servers endpoint ignores the `q` param and always returns a static
141
- * ranked list, so server-side filtering is the only way to get relevant results.
142
103
  *
143
- * @param {object} kvCollection - MongoDB ap_kv collection
144
104
  * @param {string} query - Search term (e.g. "mast")
145
105
  * @param {number} [limit=10] - Max results
146
106
  * @returns {Promise<Array>}
147
107
  */
148
- export async function searchInstances(kvCollection, query, limit = 10) {
108
+ export async function searchInstances(query, limit = 10) {
149
109
  const q = (query || "").trim().toLowerCase();
150
110
  if (!q) return [];
151
111
 
152
- const allServers = await getAllServers(kvCollection);
112
+ const allServers = await getAllServers();
153
113
 
154
114
  return allServers
155
115
  .filter(
@@ -166,13 +126,12 @@ export async function searchInstances(kvCollection, query, limit = 10) {
166
126
  *
167
127
  * Cached per domain for 24 hours.
168
128
  *
169
- * @param {object} kvCollection - MongoDB ap_kv collection
170
129
  * @param {string} domain - Instance hostname
171
130
  * @returns {Promise<{ supported: boolean, error: string|null }>}
172
131
  */
173
- export async function checkInstanceTimeline(kvCollection, domain) {
132
+ export async function checkInstanceTimeline(domain) {
174
133
  const cacheKey = `fedidb:timeline-check:${domain}`;
175
- const cached = await getFromCache(kvCollection, cacheKey);
134
+ const cached = await cacheGet(cacheKey);
176
135
  if (cached) return cached;
177
136
 
178
137
  try {
@@ -193,7 +152,7 @@ export async function checkInstanceTimeline(kvCollection, domain) {
193
152
  result = { supported: false, error: errorMsg };
194
153
  }
195
154
 
196
- await writeToCache(kvCollection, cacheKey, result);
155
+ await cacheSet(cacheKey, result, CACHE_TTL_SECONDS);
197
156
  return result;
198
157
  } catch {
199
158
  return { supported: false, error: "Connection failed" };
@@ -206,13 +165,12 @@ export async function checkInstanceTimeline(kvCollection, domain) {
206
165
  *
207
166
  * Cached for 24 hours (single cache entry).
208
167
  *
209
- * @param {object} kvCollection - MongoDB ap_kv collection
210
168
  * @param {number} [limit=50] - Max accounts to fetch
211
169
  * @returns {Promise<Array>}
212
170
  */
213
- export async function getPopularAccounts(kvCollection, limit = 50) {
171
+ export async function getPopularAccounts(limit = 50) {
214
172
  const cacheKey = `fedidb:popular-accounts:${limit}`;
215
- const cached = await getFromCache(kvCollection, cacheKey);
173
+ const cached = await cacheGet(cacheKey);
216
174
  if (cached) return cached;
217
175
 
218
176
  try {
@@ -234,7 +192,7 @@ export async function getPopularAccounts(kvCollection, limit = 50) {
234
192
  bio: (a.bio || "").replace(/<[^>]*>/g, "").slice(0, 120),
235
193
  }));
236
194
 
237
- await writeToCache(kvCollection, cacheKey, results);
195
+ await cacheSet(cacheKey, results, CACHE_TTL_SECONDS);
238
196
  return results;
239
197
  } catch {
240
198
  return [];
@@ -12,6 +12,8 @@
12
12
  * New items will have URLs populated by the fixed extractObjectData() (Task 1).
13
13
  */
14
14
 
15
+ import { cacheGet, cacheSet } from "../redis-cache.js";
16
+
15
17
  const MIGRATION_KEY = "migration:separate-mentions";
16
18
 
17
19
  /**
@@ -20,11 +22,11 @@ const MIGRATION_KEY = "migration:separate-mentions";
20
22
  * @returns {Promise<{ skipped: boolean, updated: number }>}
21
23
  */
22
24
  export async function runSeparateMentionsMigration(collections) {
23
- const { ap_kv, ap_timeline } = collections;
25
+ const { ap_timeline } = collections;
24
26
 
25
27
  // Check if already completed
26
- const state = await ap_kv.findOne({ _id: MIGRATION_KEY });
27
- if (state?.value?.completed) {
28
+ const state = await cacheGet(MIGRATION_KEY);
29
+ if (state?.completed) {
28
30
  return { skipped: true, updated: 0 };
29
31
  }
30
32
 
@@ -35,11 +37,7 @@ export async function runSeparateMentionsMigration(collections) {
35
37
 
36
38
  if (docs.length === 0) {
37
39
  // No docs to migrate — mark complete immediately
38
- await ap_kv.updateOne(
39
- { _id: MIGRATION_KEY },
40
- { $set: { value: { completed: true, date: new Date().toISOString(), updated: 0 } } },
41
- { upsert: true }
42
- );
40
+ await cacheSet(MIGRATION_KEY, { completed: true, date: new Date().toISOString(), updated: 0 });
43
41
  return { skipped: false, updated: 0 };
44
42
  }
45
43
 
@@ -78,11 +76,7 @@ export async function runSeparateMentionsMigration(collections) {
78
76
  const updated = result.modifiedCount || 0;
79
77
 
80
78
  // Mark migration complete
81
- await ap_kv.updateOne(
82
- { _id: MIGRATION_KEY },
83
- { $set: { value: { completed: true, date: new Date().toISOString(), updated } } },
84
- { upsert: true }
85
- );
79
+ await cacheSet(MIGRATION_KEY, { completed: true, date: new Date().toISOString(), updated });
86
80
 
87
81
  return { skipped: false, updated };
88
82
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Redis-backed cache for plugin-level key-value storage.
3
+ *
4
+ * Replaces direct MongoDB ap_kv reads/writes for fedidb cache,
5
+ * batch-refollow state, and migration flags. Uses the same Redis
6
+ * connection as the Fedify message queue and KV store.
7
+ *
8
+ * All keys are prefixed with "indiekit:" to avoid collisions with
9
+ * Fedify's "fedify::" prefix.
10
+ */
11
+
12
+ import Redis from "ioredis";
13
+
14
+ const KEY_PREFIX = "indiekit:";
15
+
16
+ let _redis = null;
17
+
18
+ /**
19
+ * Initialize the Redis cache with a connection URL.
20
+ * Safe to call multiple times — reuses existing connection.
21
+ * @param {string} redisUrl - Redis connection URL
22
+ */
23
+ export function initRedisCache(redisUrl) {
24
+ if (_redis) return;
25
+ if (!redisUrl) return;
26
+ _redis = new Redis(redisUrl);
27
+ }
28
+
29
+ /**
30
+ * Get the Redis client instance (for direct use if needed).
31
+ * @returns {import("ioredis").Redis|null}
32
+ */
33
+ export function getRedisClient() {
34
+ return _redis;
35
+ }
36
+
37
+ /**
38
+ * Get a value from Redis cache.
39
+ * @param {string} key
40
+ * @returns {Promise<unknown|null>}
41
+ */
42
+ export async function cacheGet(key) {
43
+ if (!_redis) return null;
44
+ try {
45
+ const raw = await _redis.get(KEY_PREFIX + key);
46
+ if (raw === null) return null;
47
+ return JSON.parse(raw);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Set a value in Redis cache with optional TTL.
55
+ * @param {string} key
56
+ * @param {unknown} value - Must be JSON-serializable
57
+ * @param {number} [ttlSeconds] - Optional TTL in seconds (0 = no expiry)
58
+ */
59
+ export async function cacheSet(key, value, ttlSeconds = 0) {
60
+ if (!_redis) return;
61
+ try {
62
+ const raw = JSON.stringify(value);
63
+ if (ttlSeconds > 0) {
64
+ await _redis.set(KEY_PREFIX + key, raw, "EX", ttlSeconds);
65
+ } else {
66
+ await _redis.set(KEY_PREFIX + key, raw);
67
+ }
68
+ } catch {
69
+ // Cache write failure is non-critical
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Delete a key from Redis cache.
75
+ * @param {string} key
76
+ */
77
+ export async function cacheDelete(key) {
78
+ if (!_redis) return;
79
+ try {
80
+ await _redis.del(KEY_PREFIX + key);
81
+ } catch {
82
+ // Ignore
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if a key exists in Redis cache.
88
+ * @param {string} key
89
+ * @returns {Promise<boolean>}
90
+ */
91
+ export async function cacheExists(key) {
92
+ if (!_redis) return false;
93
+ try {
94
+ return (await _redis.exists(KEY_PREFIX + key)) === 1;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",