@rmdes/indiekit-endpoint-rss 1.0.15 → 1.0.17

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/README.md CHANGED
@@ -36,7 +36,8 @@ export default {
36
36
  maxItemsPerFeed: 50, // Max items per feed to cache
37
37
  fetchTimeout: 10_000, // 10 second timeout per feed
38
38
  maxConcurrentFetches: 3, // Parallel feed fetches
39
- retentionDays: 30 // Days to keep items
39
+ retentionDays: 30, // Days to keep items
40
+ minItemsPerFeed: 10 // Newest items always kept, whatever their age
40
41
  })
41
42
  ],
42
43
  // MongoDB database is REQUIRED
package/index.js CHANGED
@@ -6,14 +6,11 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
6
6
  import { feedsController } from "./lib/controllers/feeds.js";
7
7
  import { itemsController } from "./lib/controllers/items.js";
8
8
  import { statusController } from "./lib/controllers/status.js";
9
- import { startSync } from "./lib/sync.js";
9
+ import { startSync, stopSync } from "./lib/sync.js";
10
10
  import { waitForReady } from "@rmdes/indiekit-startup-gate";
11
11
 
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
 
14
- const protectedRouter = express.Router();
15
- const publicRouter = express.Router();
16
-
17
14
  const defaults = {
18
15
  mountPath: "/rssapi",
19
16
  syncInterval: 900_000, // 15 minutes
@@ -21,6 +18,7 @@ const defaults = {
21
18
  fetchTimeout: 10_000,
22
19
  maxConcurrentFetches: 3,
23
20
  retentionDays: 30,
21
+ minItemsPerFeed: 10,
24
22
  };
25
23
 
26
24
  export default class RssEndpoint {
@@ -55,44 +53,60 @@ export default class RssEndpoint {
55
53
  /**
56
54
  * Protected routes (require authentication)
57
55
  * Admin dashboard and feed management (write operations)
56
+ *
57
+ * Built once per instance. Indiekit reads this getter twice while mounting
58
+ * (once to test it, once to pass it to router.use), so a router built on
59
+ * every read registers every handler twice.
60
+ * @returns {express.Router}
58
61
  */
59
62
  get routes() {
63
+ if (this._routes) return this._routes;
64
+
65
+ const router = express.Router();
66
+
60
67
  // Dashboard
61
- protectedRouter.get("/", dashboardController.get);
68
+ router.get("/", dashboardController.get);
62
69
 
63
70
  // Manual sync trigger
64
- protectedRouter.post("/sync", dashboardController.sync);
71
+ router.post("/sync", dashboardController.sync);
65
72
 
66
73
  // Clear items and re-sync
67
- protectedRouter.post("/clear-resync", dashboardController.clearResync);
74
+ router.post("/clear-resync", dashboardController.clearResync);
68
75
 
69
76
  // Feed management (protected - requires auth)
70
- protectedRouter.post("/api/feeds", express.json(), feedsController.add);
71
- protectedRouter.delete("/api/feeds/:id", feedsController.remove);
72
- protectedRouter.patch("/api/feeds/:id", express.json(), feedsController.toggle);
77
+ router.post("/api/feeds", express.json(), feedsController.add);
78
+ router.delete("/api/feeds/:id", feedsController.remove);
79
+ router.patch("/api/feeds/:id", express.json(), feedsController.toggle);
73
80
 
74
81
  // Manual refresh (protected)
75
- protectedRouter.post("/api/refresh", statusController.refresh);
82
+ router.post("/api/refresh", statusController.refresh);
76
83
 
77
- return protectedRouter;
84
+ this._routes = router;
85
+ return router;
78
86
  }
79
87
 
80
88
  /**
81
89
  * Public routes (no authentication required)
82
90
  * Read-only JSON API endpoints for frontend
91
+ * @returns {express.Router}
83
92
  */
84
93
  get routesPublic() {
94
+ if (this._routesPublic) return this._routesPublic;
95
+
96
+ const router = express.Router();
97
+
85
98
  // Feeds API (read-only)
86
- publicRouter.get("/api/feeds", feedsController.list);
99
+ router.get("/api/feeds", feedsController.list);
87
100
 
88
101
  // Items API (read-only)
89
- publicRouter.get("/api/items", itemsController.list);
90
- publicRouter.get("/api/items/:id", itemsController.get);
102
+ router.get("/api/items", itemsController.list);
103
+ router.get("/api/items/:id", itemsController.get);
91
104
 
92
105
  // Status API (read-only)
93
- publicRouter.get("/api/status", statusController.status);
106
+ router.get("/api/status", statusController.status);
94
107
 
95
- return publicRouter;
108
+ this._routesPublic = router;
109
+ return router;
96
110
  }
97
111
 
98
112
  init(Indiekit) {
@@ -120,5 +134,6 @@ export default class RssEndpoint {
120
134
 
121
135
  destroy() {
122
136
  this._stopGate?.();
137
+ stopSync();
123
138
  }
124
139
  }
@@ -85,7 +85,7 @@ export const feedsController = {
85
85
  description: feedMeta.description,
86
86
  imageUrl: feedMeta.imageUrl,
87
87
  enabled: true,
88
- addedAt: new Date(),
88
+ addedAt: new Date().toISOString(),
89
89
  lastFetchedAt: null,
90
90
  lastError: null,
91
91
  itemCount: 0,
package/lib/sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { RssClient } from "./rss-client.js";
2
2
 
3
3
  let syncInterval = null;
4
+ let initialSyncTimeout = null;
4
5
  let syncState = {
5
6
  lastSync: null,
6
7
  syncing: false,
@@ -25,12 +26,16 @@ export function getSyncState() {
25
26
  export function startSync(Indiekit, options) {
26
27
  const intervalMs = options.syncInterval || 900_000; // 15 minutes default
27
28
 
29
+ // Starting twice would orphan the previous timers with no handle to clear.
30
+ stopSync();
31
+
28
32
  console.log(
29
33
  `[RSS] Starting background sync with ${intervalMs / 60_000}min interval`
30
34
  );
31
35
 
32
36
  // Initial sync after delay
33
- setTimeout(() => {
37
+ initialSyncTimeout = setTimeout(() => {
38
+ initialSyncTimeout = null;
34
39
  runSync(Indiekit, options).catch((err) => {
35
40
  console.error("[RSS] Initial sync error:", err.message);
36
41
  });
@@ -48,9 +53,16 @@ export function startSync(Indiekit, options) {
48
53
  * Stop background sync
49
54
  */
50
55
  export function stopSync() {
51
- if (syncInterval) {
52
- clearInterval(syncInterval);
53
- syncInterval = null;
56
+ const wasRunning = Boolean(syncInterval || initialSyncTimeout);
57
+
58
+ // Both timers matter: destroy() within the first 10 seconds would otherwise
59
+ // still fire an initial sync against a torn-down plugin.
60
+ clearTimeout(initialSyncTimeout);
61
+ initialSyncTimeout = null;
62
+ clearInterval(syncInterval);
63
+ syncInterval = null;
64
+
65
+ if (wasRunning) {
54
66
  console.log("[RSS] Background sync stopped");
55
67
  }
56
68
  }
@@ -88,6 +100,7 @@ export async function runSync(dbOrIndiekit, options) {
88
100
 
89
101
  // Create indexes if they don't exist
90
102
  await createIndexes(feedsCollection, itemsCollection);
103
+ await normalizeLegacyFeedDates(feedsCollection);
91
104
 
92
105
  // Get all enabled feeds
93
106
  const feeds = await feedsCollection.find({ enabled: true }).toArray();
@@ -122,6 +135,7 @@ export async function runSync(dbOrIndiekit, options) {
122
135
  itemsCollection,
123
136
  feedsCollection,
124
137
  retentionDays,
138
+ options.minItemsPerFeed ?? 10,
125
139
  );
126
140
 
127
141
  syncState.lastSync = new Date().toISOString();
@@ -258,40 +272,96 @@ async function createIndexes(feedsCollection, itemsCollection) {
258
272
  }
259
273
 
260
274
  /**
261
- * Prune items older than retention period and update feed item counts
275
+ * Rewrite legacy BSON Date values of `addedAt` as ISO 8601 strings.
276
+ *
277
+ * Indiekit stores dates as ISO strings. Feeds added before that was fixed hold
278
+ * a BSON Date, and in BSON a Date sorts after every string — so a mixed
279
+ * collection buries newly added feeds at the bottom of { addedAt: -1 }.
280
+ * No-op once every row has been converted.
281
+ * @param {Collection} feedsCollection - Feeds collection
282
+ * @returns {Promise<number>} Number of feeds normalized
283
+ */
284
+ export async function normalizeLegacyFeedDates(feedsCollection) {
285
+ const legacy = await feedsCollection
286
+ .find({ addedAt: { $type: "date" } })
287
+ .toArray();
288
+
289
+ for (const feed of legacy) {
290
+ await feedsCollection.updateOne(
291
+ { _id: feed._id },
292
+ { $set: { addedAt: feed.addedAt.toISOString() } },
293
+ );
294
+ }
295
+
296
+ if (legacy.length > 0) {
297
+ console.log(`[RSS] Normalized addedAt on ${legacy.length} feed(s)`);
298
+ }
299
+
300
+ return legacy.length;
301
+ }
302
+
303
+ /**
304
+ * Prune items older than the retention period.
305
+ *
306
+ * The newest `minItemsPerFeed` items of every feed are kept whatever their
307
+ * age. Without that floor a low-traffic feed whose whole backlog predates the
308
+ * cutoff is emptied and refetched on every single sync, so it churns forever
309
+ * and always reads as empty.
262
310
  * @param {Collection} itemsCollection - Items collection
263
311
  * @param {Collection} feedsCollection - Feeds collection
264
312
  * @param {number} retentionDays - Days to keep items
313
+ * @param {number} minItemsPerFeed - Newest items always kept per feed
265
314
  * @returns {Promise<number>} Number of items pruned
266
315
  */
267
- async function pruneOldItems(itemsCollection, feedsCollection, retentionDays) {
316
+ export async function pruneOldItems(
317
+ itemsCollection,
318
+ feedsCollection,
319
+ retentionDays,
320
+ minItemsPerFeed = 10,
321
+ ) {
268
322
  const cutoff = new Date();
269
323
  cutoff.setDate(cutoff.getDate() - retentionDays);
270
324
 
271
325
  try {
272
- const result = await itemsCollection.deleteMany({
273
- pubDate: { $lt: cutoff },
274
- });
275
-
276
- if (result.deletedCount > 0) {
277
- console.log(
278
- `[RSS] Pruned ${result.deletedCount} items older than ${retentionDays} days`,
279
- );
280
-
281
- // Update item counts for all feeds
282
- const feeds = await feedsCollection.find({}).toArray();
283
- for (const feed of feeds) {
284
- const count = await itemsCollection.countDocuments({
326
+ const feeds = await feedsCollection.find({}).toArray();
327
+ let deletedCount = 0;
328
+
329
+ for (const feed of feeds) {
330
+ const keep = await itemsCollection
331
+ .find({ feedId: feed._id })
332
+ .sort({ pubDate: -1 })
333
+ .limit(minItemsPerFeed)
334
+ .project({ _id: 1 })
335
+ .toArray();
336
+
337
+ const result = await itemsCollection.deleteMany({
338
+ feedId: feed._id,
339
+ // $ne: null also excludes missing dates: in BSON null sorts before
340
+ // Date, so a bare $lt would delete every undated item as "too old".
341
+ pubDate: { $lt: cutoff, $ne: null },
342
+ _id: { $nin: keep.map((item) => item._id) },
343
+ });
344
+
345
+ if (result.deletedCount > 0) {
346
+ deletedCount += result.deletedCount;
347
+
348
+ const itemCount = await itemsCollection.countDocuments({
285
349
  feedId: feed._id,
286
350
  });
287
351
  await feedsCollection.updateOne(
288
352
  { _id: feed._id },
289
- { $set: { itemCount: count } },
353
+ { $set: { itemCount } },
290
354
  );
291
355
  }
292
356
  }
293
357
 
294
- return result.deletedCount;
358
+ if (deletedCount > 0) {
359
+ console.log(
360
+ `[RSS] Pruned ${deletedCount} items older than ${retentionDays} days`,
361
+ );
362
+ }
363
+
364
+ return deletedCount;
295
365
  } catch (err) {
296
366
  console.error("[RSS] Prune error:", err.message);
297
367
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -30,6 +30,9 @@
30
30
  },
31
31
  "type": "module",
32
32
  "main": "index.js",
33
+ "scripts": {
34
+ "test": "node --test \"test/*.test.js\""
35
+ },
33
36
  "exports": {
34
37
  ".": "./index.js"
35
38
  },