@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.58

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.
Files changed (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +251 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Microsub Reader — client-side JS module
3
+ *
4
+ * Reads configuration from data-* attributes and meta tags:
5
+ * #timeline[data-channel] — channel UID (channel view only)
6
+ * #timeline-loader[data-api-url] — HTML fragment endpoint
7
+ * #timeline-loader[data-cursor] — initial pagination cursor
8
+ * #timeline-loader[data-show-read] — show read items flag ("true"/"false")
9
+ * meta[name="csrf-token"] — CSRF token
10
+ * .js-mark-view-read[data-channel] — mark-view-as-read button (channel view only)
11
+ */
12
+
13
+ // CSRF token for all AJAX requests
14
+ const csrfToken =
15
+ document.querySelector('meta[name="csrf-token"]')?.content || "";
16
+
17
+ const timeline = document.getElementById("timeline");
18
+
19
+ if (timeline) {
20
+ // === Keyboard navigation (j / k / o) ===
21
+ // Q17: use a function so newly loaded items are always included
22
+ function getItems() {
23
+ return Array.from(timeline.querySelectorAll(".ms-item-card"));
24
+ }
25
+
26
+ let currentIndex = -1;
27
+
28
+ function focusItem(index) {
29
+ const items = getItems();
30
+ if (items[currentIndex]) {
31
+ items[currentIndex].classList.remove("ms-item-card--focused");
32
+ }
33
+ currentIndex = Math.max(0, Math.min(index, items.length - 1));
34
+ if (items[currentIndex]) {
35
+ items[currentIndex].classList.add("ms-item-card--focused");
36
+ items[currentIndex].scrollIntoView({ behavior: "smooth", block: "center" });
37
+ }
38
+ }
39
+
40
+ document.addEventListener("keydown", (e) => {
41
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
42
+
43
+ const items = getItems();
44
+ switch (e.key) {
45
+ case "j":
46
+ e.preventDefault();
47
+ focusItem(currentIndex + 1);
48
+ break;
49
+ case "k":
50
+ e.preventDefault();
51
+ focusItem(currentIndex - 1);
52
+ break;
53
+ case "o":
54
+ case "Enter":
55
+ e.preventDefault();
56
+ if (items[currentIndex]) {
57
+ const link = items[currentIndex].querySelector(".ms-item-card__link");
58
+ if (link) link.click();
59
+ }
60
+ break;
61
+ }
62
+ });
63
+
64
+ // Microsub API URL — strip "/reader" suffix so we hit the protocol endpoint
65
+ const microsubApiUrl = (timeline.dataset.apiBase || location.pathname)
66
+ .replace(/\/reader.*$/, "");
67
+
68
+ // === Individual mark-read button ===
69
+ const channelUid = timeline.dataset.channel;
70
+
71
+ timeline.addEventListener("click", async (e) => {
72
+ const button = e.target.closest(".ms-item-actions__mark-read");
73
+ if (!button) return;
74
+
75
+ e.preventDefault();
76
+ e.stopPropagation();
77
+
78
+ const itemId = button.dataset.itemId;
79
+ // In timeline view the channel uid lives on the button; in channel view it's on #timeline
80
+ const effectiveChannel =
81
+ button.dataset.channelUid ||
82
+ button.dataset.channelId ||
83
+ channelUid;
84
+ if (!itemId || !effectiveChannel) return;
85
+
86
+ button.disabled = true;
87
+
88
+ try {
89
+ const formData = new URLSearchParams();
90
+ formData.append("action", "timeline");
91
+ formData.append("method", "mark_read");
92
+ formData.append("channel", effectiveChannel);
93
+ formData.append("entry", itemId);
94
+
95
+ const response = await fetch(microsubApiUrl, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/x-www-form-urlencoded",
99
+ "X-CSRF-Token": csrfToken,
100
+ },
101
+ body: formData.toString(),
102
+ credentials: "same-origin",
103
+ });
104
+
105
+ if (response.ok) {
106
+ const card = button.closest(".ms-item-card");
107
+ if (card) {
108
+ card.style.transition = "opacity 0.3s ease, transform 0.3s ease";
109
+ card.style.opacity = "0";
110
+ card.style.transform = "translateX(-20px)";
111
+ setTimeout(() => {
112
+ // In timeline view items are wrapped in .ms-timeline-view__item
113
+ const wrapper = card.closest(".ms-timeline-view__item");
114
+ if (wrapper) wrapper.remove();
115
+ else card.remove();
116
+ if (timeline.querySelectorAll(".ms-item-card").length === 0) {
117
+ location.reload();
118
+ }
119
+ }, 300);
120
+ }
121
+ } else {
122
+ console.error("Failed to mark item as read");
123
+ button.disabled = false;
124
+ }
125
+ } catch (error) {
126
+ console.error("Error marking item as read:", error);
127
+ button.disabled = false;
128
+ }
129
+ });
130
+
131
+ // === Mark-source-read popover toggle ===
132
+ timeline.addEventListener("click", (e) => {
133
+ const caret = e.target.closest(".ms-item-actions__mark-read-caret");
134
+ if (!caret) return;
135
+
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+
139
+ // Close other open popovers
140
+ for (const p of timeline.querySelectorAll(
141
+ ".ms-item-actions__mark-read-popover:not([hidden])",
142
+ )) {
143
+ if (p !== caret.nextElementSibling) p.hidden = true;
144
+ }
145
+
146
+ const popover = caret.nextElementSibling;
147
+ if (popover) popover.hidden = !popover.hidden;
148
+ });
149
+
150
+ // === Mark-source-read button ===
151
+ timeline.addEventListener("click", async (e) => {
152
+ const button = e.target.closest(".ms-item-actions__mark-source-read");
153
+ if (!button) return;
154
+
155
+ e.preventDefault();
156
+ e.stopPropagation();
157
+
158
+ const feedId = button.dataset.feedId;
159
+ const effectiveChannel =
160
+ button.dataset.channelUid ||
161
+ button.dataset.channelId ||
162
+ channelUid;
163
+ if (!feedId || !effectiveChannel) return;
164
+
165
+ button.disabled = true;
166
+
167
+ try {
168
+ const formData = new URLSearchParams();
169
+ formData.append("action", "timeline");
170
+ formData.append("method", "mark_read_source");
171
+ formData.append("channel", effectiveChannel);
172
+ formData.append("feed", feedId);
173
+
174
+ const response = await fetch(microsubApiUrl, {
175
+ method: "POST",
176
+ headers: {
177
+ "Content-Type": "application/x-www-form-urlencoded",
178
+ "X-CSRF-Token": csrfToken,
179
+ },
180
+ body: formData.toString(),
181
+ credentials: "same-origin",
182
+ });
183
+
184
+ if (response.ok) {
185
+ const cards = timeline.querySelectorAll(
186
+ `.ms-item-card[data-feed-id="${feedId}"]`,
187
+ );
188
+ for (const card of cards) {
189
+ card.style.transition = "opacity 0.3s ease, transform 0.3s ease";
190
+ card.style.opacity = "0";
191
+ card.style.transform = "translateX(-20px)";
192
+ }
193
+ setTimeout(() => {
194
+ for (const card of [...cards]) {
195
+ const wrapper = card.closest(".ms-timeline-view__item");
196
+ if (wrapper) wrapper.remove();
197
+ else card.remove();
198
+ }
199
+ if (timeline.querySelectorAll(".ms-item-card").length === 0) {
200
+ location.reload();
201
+ }
202
+ }, 300);
203
+ } else {
204
+ button.disabled = false;
205
+ }
206
+ } catch (error) {
207
+ console.error("Error marking source as read:", error);
208
+ button.disabled = false;
209
+ }
210
+ });
211
+
212
+ // === Close popovers on outside click ===
213
+ document.addEventListener("click", (e) => {
214
+ if (!e.target.closest(".ms-item-actions__mark-read-group")) {
215
+ for (const p of timeline.querySelectorAll(
216
+ ".ms-item-actions__mark-read-popover:not([hidden])",
217
+ )) {
218
+ p.hidden = true;
219
+ }
220
+ }
221
+ });
222
+
223
+ // === Save-for-later ===
224
+ timeline.addEventListener("click", async (e) => {
225
+ const button = e.target.closest(".ms-item-actions__save-later");
226
+ if (!button) return;
227
+
228
+ e.preventDefault();
229
+ e.stopPropagation();
230
+
231
+ const url = button.dataset.url;
232
+ const title = button.dataset.title;
233
+ if (!url) return;
234
+
235
+ button.disabled = true;
236
+
237
+ try {
238
+ const response = await fetch("/readlater/save", {
239
+ method: "POST",
240
+ headers: {
241
+ "Content-Type": "application/json",
242
+ "X-CSRF-Token": csrfToken,
243
+ },
244
+ body: JSON.stringify({ url, title: title || url, source: "microsub" }),
245
+ credentials: "same-origin",
246
+ });
247
+
248
+ if (response.ok) {
249
+ button.classList.add("ms-item-actions__save-later--saved");
250
+ button.title = "Saved";
251
+ } else {
252
+ button.disabled = false;
253
+ }
254
+ } catch {
255
+ button.disabled = false;
256
+ }
257
+ });
258
+ }
259
+
260
+ // === Infinite scroll ===
261
+ const loader = document.getElementById("timeline-loader");
262
+ if (loader && timeline) {
263
+ const spinner = loader.querySelector(".ms-timeline__spinner");
264
+ const loadMoreLink = loader.querySelector(".ms-timeline__load-more");
265
+ const endMessage = loader.querySelector(".ms-timeline__end");
266
+ let cursor = loader.dataset.cursor;
267
+ let loading = false;
268
+ let hasMore = true;
269
+ const apiUrl = loader.dataset.apiUrl;
270
+ const showReadParam = loader.dataset.showRead;
271
+
272
+ async function loadMore() {
273
+ if (loading || !hasMore) return;
274
+ loading = true;
275
+ if (spinner) spinner.style.display = "";
276
+ if (loadMoreLink) loadMoreLink.style.display = "none";
277
+
278
+ try {
279
+ // Build query: some apiUrls already include a query string (timeline view)
280
+ const sep = apiUrl.includes("?") ? "&" : "?";
281
+ let query = `${sep}after=${encodeURIComponent(cursor)}`;
282
+ if (showReadParam === "true") query += "&showRead=true";
283
+
284
+ const response = await fetch(`${apiUrl}${query}`, {
285
+ credentials: "same-origin",
286
+ });
287
+
288
+ if (!response.ok) throw new Error("Failed to load");
289
+
290
+ const data = await response.json();
291
+
292
+ if (data.html && data.count > 0) {
293
+ timeline.insertAdjacentHTML("beforeend", data.html);
294
+ }
295
+
296
+ if (data.paging?.after) {
297
+ cursor = data.paging.after;
298
+ if (loadMoreLink) {
299
+ loadMoreLink.href = `?after=${cursor}${showReadParam === "true" ? "&showRead=true" : ""}`;
300
+ loadMoreLink.style.display = "";
301
+ }
302
+ } else {
303
+ hasMore = false;
304
+ if (loadMoreLink) loadMoreLink.style.display = "none";
305
+ if (endMessage) endMessage.style.display = "";
306
+ }
307
+ } catch (error) {
308
+ console.error("Infinite scroll error:", error);
309
+ hasMore = false;
310
+ if (loadMoreLink) loadMoreLink.style.display = "";
311
+ } finally {
312
+ loading = false;
313
+ if (spinner) spinner.style.display = "none";
314
+ }
315
+ }
316
+
317
+ // Q16: listen for CustomEvent instead of exposing window global
318
+ document.addEventListener("microsub:trigger-load", () => loadMore());
319
+
320
+ // IntersectionObserver auto-loads when sentinel is visible
321
+ const observer = new IntersectionObserver(
322
+ (entries) => {
323
+ if (entries[0].isIntersecting && hasMore && !loading) {
324
+ loadMore();
325
+ }
326
+ },
327
+ { rootMargin: "200px" },
328
+ );
329
+ observer.observe(loader);
330
+
331
+ // Click handler for fallback link
332
+ if (loadMoreLink) {
333
+ loadMoreLink.addEventListener("click", (e) => {
334
+ e.preventDefault();
335
+ loadMore();
336
+ });
337
+ }
338
+ }
339
+
340
+ // === Mark current view as read (channel view only) ===
341
+ const markViewBtn = document.querySelector(".js-mark-view-read");
342
+ if (markViewBtn && timeline) {
343
+ // Show the button (hidden by default for noscript compat)
344
+ markViewBtn.style.display = "";
345
+
346
+ // Derive Microsub API URL from current path
347
+ const markViewApiUrl = location.pathname.replace(/\/reader.*$/, "");
348
+
349
+ markViewBtn.addEventListener("click", async () => {
350
+ const unreadCards = timeline.querySelectorAll(
351
+ ".ms-item-card:not(.ms-item-card--read)",
352
+ );
353
+ const itemIds = [...unreadCards]
354
+ .map((card) => card.dataset.itemId)
355
+ .filter(Boolean);
356
+
357
+ if (itemIds.length === 0) return;
358
+
359
+ markViewBtn.disabled = true;
360
+
361
+ const formData = new URLSearchParams();
362
+ formData.append("action", "timeline");
363
+ formData.append("method", "mark_read");
364
+ formData.append("channel", markViewBtn.dataset.channel);
365
+ for (const id of itemIds) {
366
+ formData.append("entry", id);
367
+ }
368
+
369
+ try {
370
+ const response = await fetch(markViewApiUrl, {
371
+ method: "POST",
372
+ headers: {
373
+ "Content-Type": "application/x-www-form-urlencoded",
374
+ "X-CSRF-Token": csrfToken,
375
+ },
376
+ body: formData.toString(),
377
+ credentials: "same-origin",
378
+ });
379
+
380
+ if (response.ok) {
381
+ for (const card of unreadCards) {
382
+ card.style.transition = "opacity 0.3s ease, transform 0.3s ease";
383
+ card.style.opacity = "0";
384
+ card.style.transform = "translateX(-20px)";
385
+ }
386
+ setTimeout(() => {
387
+ for (const card of [...unreadCards]) card.remove();
388
+ markViewBtn.disabled = false;
389
+
390
+ // Q16: dispatch CustomEvent to trigger infinite scroll load
391
+ document.dispatchEvent(new CustomEvent("microsub:trigger-load"));
392
+
393
+ if (
394
+ !document.getElementById("timeline-loader") &&
395
+ timeline.querySelectorAll(".ms-item-card").length === 0
396
+ ) {
397
+ location.reload();
398
+ }
399
+ }, 300);
400
+ } else {
401
+ markViewBtn.disabled = false;
402
+ }
403
+ } catch (error) {
404
+ console.error("Error marking current view as read:", error);
405
+ markViewBtn.disabled = false;
406
+ }
407
+ });
408
+ }
package/index.js CHANGED
@@ -6,16 +6,17 @@ import rateLimit from "express-rate-limit";
6
6
 
7
7
  import { microsubController } from "./lib/controllers/microsub.js";
8
8
  import { opmlController } from "./lib/controllers/opml.js";
9
- import { readerController } from "./lib/controllers/reader.js";
9
+ import { readerController } from "./lib/controllers/reader/index.js";
10
+ import { asyncHandler } from "./lib/utils/async-handler.js";
10
11
  import { handleMediaProxy } from "./lib/media/proxy.js";
11
12
  import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
12
13
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
13
14
  import { ensureActivityPubChannel } from "./lib/storage/channels.js";
15
+ import { createIndexes } from "./lib/storage/items.js";
14
16
  import {
15
17
  cleanupAllReadItems,
16
18
  cleanupStaleItems,
17
- createIndexes,
18
- } from "./lib/storage/items.js";
19
+ } from "./lib/storage/items-retention.js";
19
20
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
20
21
  import { websubHandler } from "./lib/websub/handler.js";
21
22
 
@@ -87,61 +88,61 @@ export default class MicrosubEndpoint {
87
88
  readerRouter.use(csrfToken);
88
89
  readerRouter.use(csrfValidate);
89
90
 
90
- readerRouter.get("/", readerController.index);
91
- readerRouter.get("/channels", readerController.channels);
92
- readerRouter.get("/channels/new", readerController.newChannel);
93
- readerRouter.post("/channels/new", readerController.createChannel);
94
- readerRouter.get("/channels/:uid/html", readerController.channelHtml);
95
- readerRouter.get("/channels/:uid", readerController.channel);
96
- readerRouter.get("/channels/:uid/settings", readerController.settings);
91
+ readerRouter.get("/", asyncHandler(readerController.index));
92
+ readerRouter.get("/channels", asyncHandler(readerController.channels));
93
+ readerRouter.get("/channels/new", asyncHandler(readerController.newChannel));
94
+ readerRouter.post("/channels/new", asyncHandler(readerController.createChannel));
95
+ readerRouter.get("/channels/:uid/html", asyncHandler(readerController.channelHtml));
96
+ readerRouter.get("/channels/:uid", asyncHandler(readerController.channel));
97
+ readerRouter.get("/channels/:uid/settings", asyncHandler(readerController.settings));
97
98
  readerRouter.post(
98
99
  "/channels/:uid/settings",
99
- readerController.updateSettings,
100
+ asyncHandler(readerController.updateSettings),
100
101
  );
101
- readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
102
- readerRouter.get("/channels/:uid/feeds", readerController.feeds);
103
- readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
102
+ readerRouter.post("/channels/:uid/delete", asyncHandler(readerController.deleteChannel));
103
+ readerRouter.get("/channels/:uid/feeds", asyncHandler(readerController.feeds));
104
+ readerRouter.post("/channels/:uid/feeds", asyncHandler(readerController.addFeed));
104
105
  readerRouter.post(
105
106
  "/channels/:uid/feeds/remove",
106
- readerController.removeFeed,
107
+ asyncHandler(readerController.removeFeed),
107
108
  );
108
109
  readerRouter.get(
109
110
  "/channels/:uid/feeds/:feedId",
110
- readerController.feedDetails,
111
+ asyncHandler(readerController.feedDetails),
111
112
  );
112
113
  readerRouter.get(
113
114
  "/channels/:uid/feeds/:feedId/edit",
114
- readerController.editFeedForm,
115
+ asyncHandler(readerController.editFeedForm),
115
116
  );
116
117
  readerRouter.post(
117
118
  "/channels/:uid/feeds/:feedId/edit",
118
- readerController.updateFeedUrl,
119
+ asyncHandler(readerController.updateFeedUrl),
119
120
  );
120
121
  readerRouter.post(
121
122
  "/channels/:uid/feeds/:feedId/rediscover",
122
- readerController.rediscoverFeed,
123
+ asyncHandler(readerController.rediscoverFeed),
123
124
  );
124
125
  readerRouter.post(
125
126
  "/channels/:uid/feeds/:feedId/refresh",
126
- readerController.refreshFeed,
127
+ asyncHandler(readerController.refreshFeed),
127
128
  );
128
- readerRouter.get("/item/:id", readerController.item);
129
- readerRouter.get("/compose", readerController.compose);
130
- readerRouter.post("/compose", readerController.submitCompose);
131
- readerRouter.get("/search", readerController.searchPage);
132
- readerRouter.post("/search", readerController.searchFeeds);
133
- readerRouter.post("/subscribe", readerController.subscribe);
134
- readerRouter.get("/actor", readerController.actorProfile);
135
- readerRouter.post("/actor/follow", readerController.followActorAction);
136
- readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
137
- readerRouter.post("/api/mark-read", readerController.markAllRead);
138
- readerRouter.post("/api/mark-view-read", readerController.markViewRead);
129
+ readerRouter.get("/item/:id", asyncHandler(readerController.item));
130
+ readerRouter.get("/compose", asyncHandler(readerController.compose));
131
+ readerRouter.post("/compose", asyncHandler(readerController.submitCompose));
132
+ readerRouter.get("/search", asyncHandler(readerController.searchPage));
133
+ readerRouter.post("/search", asyncHandler(readerController.searchFeeds));
134
+ readerRouter.post("/subscribe", asyncHandler(readerController.subscribe));
135
+ readerRouter.get("/actor", asyncHandler(readerController.actorProfile));
136
+ readerRouter.post("/actor/follow", asyncHandler(readerController.followActorAction));
137
+ readerRouter.post("/actor/unfollow", asyncHandler(readerController.unfollowActorAction));
138
+ readerRouter.post("/api/mark-read", asyncHandler(readerController.markAllRead));
139
+ readerRouter.post("/api/mark-view-read", asyncHandler(readerController.markViewRead));
139
140
  readerRouter.get("/opml", opmlController.exportOpml);
140
- readerRouter.get("/timeline/html", readerController.timelineHtml);
141
- readerRouter.get("/timeline", readerController.timeline);
142
- readerRouter.get("/deck", readerController.deck);
143
- readerRouter.get("/deck/settings", readerController.deckSettings);
144
- readerRouter.post("/deck/settings", readerController.saveDeckSettings);
141
+ readerRouter.get("/timeline/html", asyncHandler(readerController.timelineHtml));
142
+ readerRouter.get("/timeline", asyncHandler(readerController.timeline));
143
+ readerRouter.get("/deck", asyncHandler(readerController.deck));
144
+ readerRouter.get("/deck/settings", asyncHandler(readerController.deckSettings));
145
+ readerRouter.post("/deck/settings", asyncHandler(readerController.saveDeckSettings));
145
146
  router.use("/reader", readerRouter);
146
147
 
147
148
  return router;
@@ -3,8 +3,8 @@
3
3
  * @module cache/redis
4
4
  */
5
5
 
6
- import Redis from "ioredis";
7
-
6
+ // Redis is dynamically imported only when needed so the package is optional
7
+ let Redis;
8
8
  let redisClient;
9
9
 
10
10
  /**
@@ -12,7 +12,7 @@ let redisClient;
12
12
  * @param {object} application - Indiekit application
13
13
  * @returns {object|undefined} Redis client or undefined
14
14
  */
15
- export function getRedisClient(application) {
15
+ export async function getRedisClient(application) {
16
16
  // Check if Redis is already initialized on the application
17
17
  if (application.redis) {
18
18
  return application.redis;
@@ -27,6 +27,15 @@ export function getRedisClient(application) {
27
27
  const redisUrl = application.config?.application?.redisUrl;
28
28
  if (redisUrl) {
29
29
  try {
30
+ if (!Redis) {
31
+ try {
32
+ Redis = (await import("ioredis")).default;
33
+ } catch {
34
+ console.warn("[Microsub] ioredis not available");
35
+ return;
36
+ }
37
+ }
38
+
30
39
  redisClient = new Redis(redisUrl, {
31
40
  maxRetriesPerRequest: 3,
32
41
  retryStrategy(times) {