@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 +2 -1
- package/index.js +32 -17
- package/lib/controllers/feeds.js +1 -1
- package/lib/sync.js +91 -21
- package/package.json +4 -1
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
|
|
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
|
-
|
|
68
|
+
router.get("/", dashboardController.get);
|
|
62
69
|
|
|
63
70
|
// Manual sync trigger
|
|
64
|
-
|
|
71
|
+
router.post("/sync", dashboardController.sync);
|
|
65
72
|
|
|
66
73
|
// Clear items and re-sync
|
|
67
|
-
|
|
74
|
+
router.post("/clear-resync", dashboardController.clearResync);
|
|
68
75
|
|
|
69
76
|
// Feed management (protected - requires auth)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
82
|
+
router.post("/api/refresh", statusController.refresh);
|
|
76
83
|
|
|
77
|
-
|
|
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
|
-
|
|
99
|
+
router.get("/api/feeds", feedsController.list);
|
|
87
100
|
|
|
88
101
|
// Items API (read-only)
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
router.get("/api/items", itemsController.list);
|
|
103
|
+
router.get("/api/items/:id", itemsController.get);
|
|
91
104
|
|
|
92
105
|
// Status API (read-only)
|
|
93
|
-
|
|
106
|
+
router.get("/api/status", statusController.status);
|
|
94
107
|
|
|
95
|
-
|
|
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
|
}
|
package/lib/controllers/feeds.js
CHANGED
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
353
|
+
{ $set: { itemCount } },
|
|
290
354
|
);
|
|
291
355
|
}
|
|
292
356
|
}
|
|
293
357
|
|
|
294
|
-
|
|
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.
|
|
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
|
},
|