@rmdes/indiekit-endpoint-rss 1.0.16 → 1.0.18

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
@@ -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
@@ -56,44 +53,60 @@ export default class RssEndpoint {
56
53
  /**
57
54
  * Protected routes (require authentication)
58
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}
59
61
  */
60
62
  get routes() {
63
+ if (this._routes) return this._routes;
64
+
65
+ const router = express.Router();
66
+
61
67
  // Dashboard
62
- protectedRouter.get("/", dashboardController.get);
68
+ router.get("/", dashboardController.get);
63
69
 
64
70
  // Manual sync trigger
65
- protectedRouter.post("/sync", dashboardController.sync);
71
+ router.post("/sync", dashboardController.sync);
66
72
 
67
73
  // Clear items and re-sync
68
- protectedRouter.post("/clear-resync", dashboardController.clearResync);
74
+ router.post("/clear-resync", dashboardController.clearResync);
69
75
 
70
76
  // Feed management (protected - requires auth)
71
- protectedRouter.post("/api/feeds", express.json(), feedsController.add);
72
- protectedRouter.delete("/api/feeds/:id", feedsController.remove);
73
- 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);
74
80
 
75
81
  // Manual refresh (protected)
76
- protectedRouter.post("/api/refresh", statusController.refresh);
82
+ router.post("/api/refresh", statusController.refresh);
77
83
 
78
- return protectedRouter;
84
+ this._routes = router;
85
+ return router;
79
86
  }
80
87
 
81
88
  /**
82
89
  * Public routes (no authentication required)
83
90
  * Read-only JSON API endpoints for frontend
91
+ * @returns {express.Router}
84
92
  */
85
93
  get routesPublic() {
94
+ if (this._routesPublic) return this._routesPublic;
95
+
96
+ const router = express.Router();
97
+
86
98
  // Feeds API (read-only)
87
- publicRouter.get("/api/feeds", feedsController.list);
99
+ router.get("/api/feeds", feedsController.list);
88
100
 
89
101
  // Items API (read-only)
90
- publicRouter.get("/api/items", itemsController.list);
91
- publicRouter.get("/api/items/:id", itemsController.get);
102
+ router.get("/api/items", itemsController.list);
103
+ router.get("/api/items/:id", itemsController.get);
92
104
 
93
105
  // Status API (read-only)
94
- publicRouter.get("/api/status", statusController.status);
106
+ router.get("/api/status", statusController.status);
95
107
 
96
- return publicRouter;
108
+ this._routesPublic = router;
109
+ return router;
97
110
  }
98
111
 
99
112
  init(Indiekit) {
@@ -121,5 +134,6 @@ export default class RssEndpoint {
121
134
 
122
135
  destroy() {
123
136
  this._stopGate?.();
137
+ stopSync();
124
138
  }
125
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();
@@ -258,6 +271,35 @@ async function createIndexes(feedsCollection, itemsCollection) {
258
271
  await itemsCollection.createIndex({ fetchedAt: -1 });
259
272
  }
260
273
 
274
+ /**
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
+
261
303
  /**
262
304
  * Prune items older than the retention period.
263
305
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -50,9 +50,6 @@
50
50
  "rss-parser": "^3.13.0",
51
51
  "sanitize-html": "^2.13.0"
52
52
  },
53
- "peerDependencies": {
54
- "@indiekit/indiekit": ">=1.0.0-beta.25"
55
- },
56
53
  "publishConfig": {
57
54
  "access": "public"
58
55
  }