@rmdes/indiekit-endpoint-microsub 1.0.0-beta.1

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 (52) hide show
  1. package/README.md +111 -0
  2. package/index.js +140 -0
  3. package/lib/cache/redis.js +133 -0
  4. package/lib/controllers/block.js +85 -0
  5. package/lib/controllers/channels.js +135 -0
  6. package/lib/controllers/events.js +56 -0
  7. package/lib/controllers/follow.js +108 -0
  8. package/lib/controllers/microsub.js +138 -0
  9. package/lib/controllers/mute.js +124 -0
  10. package/lib/controllers/preview.js +67 -0
  11. package/lib/controllers/reader.js +218 -0
  12. package/lib/controllers/search.js +142 -0
  13. package/lib/controllers/timeline.js +117 -0
  14. package/lib/feeds/atom.js +61 -0
  15. package/lib/feeds/fetcher.js +205 -0
  16. package/lib/feeds/hfeed.js +177 -0
  17. package/lib/feeds/jsonfeed.js +43 -0
  18. package/lib/feeds/normalizer.js +586 -0
  19. package/lib/feeds/parser.js +124 -0
  20. package/lib/feeds/rss.js +61 -0
  21. package/lib/polling/processor.js +201 -0
  22. package/lib/polling/scheduler.js +128 -0
  23. package/lib/polling/tier.js +139 -0
  24. package/lib/realtime/broker.js +241 -0
  25. package/lib/search/indexer.js +90 -0
  26. package/lib/search/query.js +197 -0
  27. package/lib/storage/channels.js +281 -0
  28. package/lib/storage/feeds.js +286 -0
  29. package/lib/storage/filters.js +265 -0
  30. package/lib/storage/items.js +419 -0
  31. package/lib/storage/read-state.js +109 -0
  32. package/lib/utils/jf2.js +170 -0
  33. package/lib/utils/pagination.js +157 -0
  34. package/lib/utils/validation.js +217 -0
  35. package/lib/webmention/processor.js +214 -0
  36. package/lib/webmention/receiver.js +54 -0
  37. package/lib/webmention/verifier.js +308 -0
  38. package/lib/websub/discovery.js +129 -0
  39. package/lib/websub/handler.js +163 -0
  40. package/lib/websub/subscriber.js +181 -0
  41. package/locales/en.json +80 -0
  42. package/package.json +54 -0
  43. package/views/channel-new.njk +33 -0
  44. package/views/channel.njk +41 -0
  45. package/views/compose.njk +61 -0
  46. package/views/item.njk +85 -0
  47. package/views/partials/actions.njk +15 -0
  48. package/views/partials/author.njk +17 -0
  49. package/views/partials/item-card.njk +65 -0
  50. package/views/partials/timeline.njk +10 -0
  51. package/views/reader.njk +37 -0
  52. package/views/settings.njk +81 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Feed parser dispatcher
3
+ * @module feeds/parser
4
+ */
5
+
6
+ import { parseAtom } from "./atom.js";
7
+ import { parseHfeed } from "./hfeed.js";
8
+ import { parseJsonFeed } from "./jsonfeed.js";
9
+ import { parseRss } from "./rss.js";
10
+
11
+ /**
12
+ * Detect feed type from content
13
+ * @param {string} content - Feed content
14
+ * @param {string} contentType - HTTP Content-Type header
15
+ * @returns {string} Feed type: 'rss' | 'atom' | 'jsonfeed' | 'hfeed' | 'unknown'
16
+ */
17
+ export function detectFeedType(content, contentType = "") {
18
+ const ct = contentType.toLowerCase();
19
+
20
+ // Check Content-Type header first
21
+ if (ct.includes("application/json") || ct.includes("application/feed+json")) {
22
+ return "jsonfeed";
23
+ }
24
+
25
+ if (ct.includes("application/atom+xml")) {
26
+ return "atom";
27
+ }
28
+
29
+ if (
30
+ ct.includes("application/rss+xml") ||
31
+ ct.includes("application/xml") ||
32
+ ct.includes("text/xml")
33
+ ) {
34
+ // Need to check content to distinguish RSS from Atom
35
+ const trimmed = content.trim();
36
+ if (
37
+ trimmed.includes("<feed") &&
38
+ trimmed.includes('xmlns="http://www.w3.org/2005/Atom"')
39
+ ) {
40
+ return "atom";
41
+ }
42
+ if (trimmed.includes("<rss") || trimmed.includes("<rdf:RDF")) {
43
+ return "rss";
44
+ }
45
+ }
46
+
47
+ if (ct.includes("text/html")) {
48
+ return "hfeed";
49
+ }
50
+
51
+ // Fall back to content inspection
52
+ const trimmed = content.trim();
53
+
54
+ // JSON Feed
55
+ if (trimmed.startsWith("{")) {
56
+ try {
57
+ const json = JSON.parse(trimmed);
58
+ if (json.version && json.version.includes("jsonfeed.org")) {
59
+ return "jsonfeed";
60
+ }
61
+ } catch {
62
+ // Not JSON
63
+ }
64
+ }
65
+
66
+ // XML feeds
67
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
68
+ if (
69
+ trimmed.includes("<feed") &&
70
+ trimmed.includes('xmlns="http://www.w3.org/2005/Atom"')
71
+ ) {
72
+ return "atom";
73
+ }
74
+ if (trimmed.includes("<rss") || trimmed.includes("<rdf:RDF")) {
75
+ return "rss";
76
+ }
77
+ }
78
+
79
+ // HTML with potential h-feed
80
+ if (trimmed.includes("<!DOCTYPE html") || trimmed.includes("<html")) {
81
+ return "hfeed";
82
+ }
83
+
84
+ return "unknown";
85
+ }
86
+
87
+ /**
88
+ * Parse feed content into normalized items
89
+ * @param {string} content - Feed content
90
+ * @param {string} feedUrl - URL of the feed
91
+ * @param {object} options - Parse options
92
+ * @param {string} [options.contentType] - HTTP Content-Type header
93
+ * @returns {Promise<object>} Parsed feed with metadata and items
94
+ */
95
+ export async function parseFeed(content, feedUrl, options = {}) {
96
+ const feedType = detectFeedType(content, options.contentType);
97
+
98
+ switch (feedType) {
99
+ case "rss": {
100
+ return parseRss(content, feedUrl);
101
+ }
102
+
103
+ case "atom": {
104
+ return parseAtom(content, feedUrl);
105
+ }
106
+
107
+ case "jsonfeed": {
108
+ return parseJsonFeed(content, feedUrl);
109
+ }
110
+
111
+ case "hfeed": {
112
+ return parseHfeed(content, feedUrl);
113
+ }
114
+
115
+ default: {
116
+ throw new Error(`Unable to detect feed type for ${feedUrl}`);
117
+ }
118
+ }
119
+ }
120
+
121
+ export { parseAtom } from "./atom.js";
122
+ export { parseHfeed } from "./hfeed.js";
123
+ export { parseJsonFeed } from "./jsonfeed.js";
124
+ export { parseRss } from "./rss.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * RSS 1.0/2.0 feed parser
3
+ * @module feeds/rss
4
+ */
5
+
6
+ import { Readable } from "node:stream";
7
+
8
+ import FeedParser from "feedparser";
9
+
10
+ import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
11
+
12
+ /**
13
+ * Parse RSS feed content
14
+ * @param {string} content - RSS XML content
15
+ * @param {string} feedUrl - URL of the feed
16
+ * @returns {Promise<object>} Parsed feed with metadata and items
17
+ */
18
+ export async function parseRss(content, feedUrl) {
19
+ return new Promise((resolve, reject) => {
20
+ const feedparser = new FeedParser({ feedurl: feedUrl });
21
+ const items = [];
22
+ let meta;
23
+
24
+ feedparser.on("error", (error) => {
25
+ reject(new Error(`RSS parse error: ${error.message}`));
26
+ });
27
+
28
+ feedparser.on("meta", (feedMeta) => {
29
+ meta = feedMeta;
30
+ });
31
+
32
+ feedparser.on("readable", function () {
33
+ let item;
34
+ while ((item = this.read())) {
35
+ items.push(item);
36
+ }
37
+ });
38
+
39
+ feedparser.on("end", () => {
40
+ try {
41
+ const normalizedMeta = normalizeFeedMeta(meta, feedUrl);
42
+ const normalizedItems = items.map((item) =>
43
+ normalizeItem(item, feedUrl, "rss"),
44
+ );
45
+
46
+ resolve({
47
+ type: "feed",
48
+ url: feedUrl,
49
+ ...normalizedMeta,
50
+ items: normalizedItems,
51
+ });
52
+ } catch (error) {
53
+ reject(error);
54
+ }
55
+ });
56
+
57
+ // Create readable stream from string and pipe to feedparser
58
+ const stream = Readable.from([content]);
59
+ stream.pipe(feedparser);
60
+ });
61
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Feed processing pipeline
3
+ * @module polling/processor
4
+ */
5
+
6
+ import { getRedisClient, publishEvent } from "../cache/redis.js";
7
+ import { fetchAndParseFeed } from "../feeds/fetcher.js";
8
+ import { getChannel } from "../storage/channels.js";
9
+ import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js";
10
+ import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
11
+ import { addItem } from "../storage/items.js";
12
+
13
+ import { calculateNewTier } from "./tier.js";
14
+
15
+ /**
16
+ * Process a single feed
17
+ * @param {object} application - Indiekit application
18
+ * @param {object} feed - Feed document from database
19
+ * @returns {Promise<object>} Processing result
20
+ */
21
+ export async function processFeed(application, feed) {
22
+ const startTime = Date.now();
23
+ const result = {
24
+ feedId: feed._id,
25
+ url: feed.url,
26
+ success: false,
27
+ itemsAdded: 0,
28
+ error: undefined,
29
+ };
30
+
31
+ try {
32
+ // Get Redis client for caching
33
+ const redis = getRedisClient(application);
34
+
35
+ // Fetch and parse the feed
36
+ const parsed = await fetchAndParseFeed(feed.url, {
37
+ etag: feed.etag,
38
+ lastModified: feed.lastModified,
39
+ redis,
40
+ });
41
+
42
+ // Handle 304 Not Modified
43
+ if (parsed.notModified) {
44
+ const tierResult = calculateNewTier({
45
+ currentTier: feed.tier,
46
+ hasNewItems: false,
47
+ consecutiveUnchanged: feed.unmodified || 0,
48
+ });
49
+
50
+ await updateFeedAfterFetch(application, feed._id, false, {
51
+ tier: tierResult.tier,
52
+ unmodified: tierResult.consecutiveUnchanged,
53
+ nextFetchAt: tierResult.nextFetchAt,
54
+ });
55
+
56
+ result.success = true;
57
+ result.notModified = true;
58
+ return result;
59
+ }
60
+
61
+ // Get channel for filtering
62
+ const channel = await getChannel(application, feed.channelId);
63
+
64
+ // Process items
65
+ let newItemCount = 0;
66
+ for (const item of parsed.items) {
67
+ // Apply channel filters
68
+ if (channel?.settings && !passesFilters(item, channel.settings)) {
69
+ continue;
70
+ }
71
+
72
+ // Store the item
73
+ const stored = await addItem(application, {
74
+ channelId: feed.channelId,
75
+ feedId: feed._id,
76
+ uid: item.uid,
77
+ item,
78
+ });
79
+ if (stored) {
80
+ newItemCount++;
81
+
82
+ // Publish real-time event
83
+ if (redis) {
84
+ await publishEvent(redis, `microsub:${feed.channelId}`, {
85
+ type: "new-item",
86
+ channelId: feed.channelId.toString(),
87
+ item: stored,
88
+ });
89
+ }
90
+ }
91
+ }
92
+
93
+ result.itemsAdded = newItemCount;
94
+
95
+ // Update tier based on whether we found new items
96
+ const tierResult = calculateNewTier({
97
+ currentTier: feed.tier,
98
+ hasNewItems: newItemCount > 0,
99
+ consecutiveUnchanged: newItemCount > 0 ? 0 : feed.unmodified || 0,
100
+ });
101
+
102
+ // Update feed metadata
103
+ const updateData = {
104
+ tier: tierResult.tier,
105
+ unmodified: tierResult.consecutiveUnchanged,
106
+ nextFetchAt: tierResult.nextFetchAt,
107
+ etag: parsed.etag,
108
+ lastModified: parsed.lastModified,
109
+ };
110
+
111
+ // Update feed title/photo if discovered
112
+ if (parsed.name && !feed.title) {
113
+ updateData.title = parsed.name;
114
+ }
115
+ if (parsed.photo && !feed.photo) {
116
+ updateData.photo = parsed.photo;
117
+ }
118
+
119
+ await updateFeedAfterFetch(
120
+ application,
121
+ feed._id,
122
+ newItemCount > 0,
123
+ updateData,
124
+ );
125
+
126
+ // Handle WebSub hub discovery
127
+ if (parsed.hub && (!feed.websub || feed.websub.hub !== parsed.hub)) {
128
+ await updateFeedWebsub(application, feed._id, {
129
+ hub: parsed.hub,
130
+ topic: parsed.self || feed.url,
131
+ });
132
+ // TODO: Subscribe to hub
133
+ }
134
+
135
+ result.success = true;
136
+ result.tier = tierResult.tier;
137
+ } catch (error) {
138
+ result.error = error.message;
139
+
140
+ // Still update the feed to prevent retry storms
141
+ try {
142
+ const tierResult = calculateNewTier({
143
+ currentTier: feed.tier,
144
+ hasNewItems: false,
145
+ consecutiveUnchanged: (feed.unmodified || 0) + 1,
146
+ });
147
+
148
+ await updateFeedAfterFetch(application, feed._id, false, {
149
+ tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error
150
+ unmodified: tierResult.consecutiveUnchanged,
151
+ nextFetchAt: tierResult.nextFetchAt,
152
+ lastError: error.message,
153
+ lastErrorAt: new Date(),
154
+ });
155
+ } catch {
156
+ // Ignore update errors
157
+ }
158
+ }
159
+
160
+ result.duration = Date.now() - startTime;
161
+ return result;
162
+ }
163
+
164
+ /**
165
+ * Check if an item passes channel filters
166
+ * @param {object} item - Feed item
167
+ * @param {object} settings - Channel settings
168
+ * @returns {boolean} Whether the item passes filters
169
+ */
170
+ function passesFilters(item, settings) {
171
+ return passesTypeFilter(item, settings) && passesRegexFilter(item, settings);
172
+ }
173
+
174
+ /**
175
+ * Process multiple feeds in batch
176
+ * @param {object} application - Indiekit application
177
+ * @param {Array} feeds - Array of feed documents
178
+ * @param {object} options - Processing options
179
+ * @returns {Promise<object>} Batch processing result
180
+ */
181
+ export async function processFeedBatch(application, feeds, options = {}) {
182
+ const { concurrency = 5 } = options;
183
+ const results = [];
184
+
185
+ // Process in batches with limited concurrency
186
+ for (let index = 0; index < feeds.length; index += concurrency) {
187
+ const batch = feeds.slice(index, index + concurrency);
188
+ const batchResults = await Promise.all(
189
+ batch.map((feed) => processFeed(application, feed)),
190
+ );
191
+ results.push(...batchResults);
192
+ }
193
+
194
+ return {
195
+ total: feeds.length,
196
+ successful: results.filter((r) => r.success).length,
197
+ failed: results.filter((r) => !r.success).length,
198
+ itemsAdded: results.reduce((sum, r) => sum + r.itemsAdded, 0),
199
+ results,
200
+ };
201
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Feed polling scheduler
3
+ * @module polling/scheduler
4
+ */
5
+
6
+ import { getFeedsToFetch } from "../storage/feeds.js";
7
+
8
+ import { processFeedBatch } from "./processor.js";
9
+
10
+ let schedulerInterval;
11
+ let indiekitInstance;
12
+ let isRunning = false;
13
+
14
+ const POLL_INTERVAL = 60 * 1000; // Run scheduler every minute
15
+ const BATCH_CONCURRENCY = 5; // Process 5 feeds at a time
16
+
17
+ /**
18
+ * Start the feed polling scheduler
19
+ * @param {object} indiekit - Indiekit instance
20
+ */
21
+ export function startScheduler(indiekit) {
22
+ if (schedulerInterval) {
23
+ return; // Already running
24
+ }
25
+
26
+ indiekitInstance = indiekit;
27
+
28
+ // Run every minute
29
+ schedulerInterval = setInterval(async () => {
30
+ await runSchedulerCycle();
31
+ }, POLL_INTERVAL);
32
+
33
+ // Run immediately on start
34
+ runSchedulerCycle();
35
+
36
+ console.log("[Microsub] Feed polling scheduler started");
37
+ }
38
+
39
+ /**
40
+ * Stop the feed polling scheduler
41
+ */
42
+ export function stopScheduler() {
43
+ if (schedulerInterval) {
44
+ clearInterval(schedulerInterval);
45
+ schedulerInterval = undefined;
46
+ }
47
+ indiekitInstance = undefined;
48
+ console.log("[Microsub] Feed polling scheduler stopped");
49
+ }
50
+
51
+ /**
52
+ * Run a single scheduler cycle
53
+ */
54
+ async function runSchedulerCycle() {
55
+ if (!indiekitInstance) {
56
+ return;
57
+ }
58
+
59
+ // Prevent overlapping runs
60
+ if (isRunning) {
61
+ return;
62
+ }
63
+
64
+ isRunning = true;
65
+
66
+ try {
67
+ const application = indiekitInstance;
68
+ const feeds = await getFeedsToFetch(application);
69
+
70
+ if (feeds.length === 0) {
71
+ isRunning = false;
72
+ return;
73
+ }
74
+
75
+ console.log(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
76
+
77
+ const result = await processFeedBatch(application, feeds, {
78
+ concurrency: BATCH_CONCURRENCY,
79
+ });
80
+
81
+ console.log(
82
+ `[Microsub] Processed ${result.total} feeds: ${result.successful} successful, ` +
83
+ `${result.failed} failed, ${result.itemsAdded} new items`,
84
+ );
85
+
86
+ // Log any errors
87
+ for (const feedResult of result.results) {
88
+ if (feedResult.error) {
89
+ console.error(
90
+ `[Microsub] Error processing ${feedResult.url}: ${feedResult.error}`,
91
+ );
92
+ }
93
+ }
94
+ } catch (error) {
95
+ console.error("[Microsub] Error in scheduler cycle:", error.message);
96
+ } finally {
97
+ isRunning = false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Manually trigger a feed refresh
103
+ * @param {object} application - Indiekit application
104
+ * @param {string} feedId - Feed ID to refresh
105
+ * @returns {Promise<object>} Processing result
106
+ */
107
+ export async function refreshFeedNow(application, feedId) {
108
+ const { getFeedById } = await import("../storage/feeds.js");
109
+ const { processFeed } = await import("./processor.js");
110
+
111
+ const feed = await getFeedById(application, feedId);
112
+ if (!feed) {
113
+ throw new Error("Feed not found");
114
+ }
115
+
116
+ return processFeed(application, feed);
117
+ }
118
+
119
+ /**
120
+ * Get scheduler status
121
+ * @returns {object} Scheduler status
122
+ */
123
+ export function getSchedulerStatus() {
124
+ return {
125
+ running: !!schedulerInterval,
126
+ processing: isRunning,
127
+ };
128
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Adaptive tier-based polling algorithm
3
+ * Based on Ekster's approach: https://github.com/pstuifzand/ekster
4
+ *
5
+ * Tier determines poll interval: interval = 2^tier minutes
6
+ * - Tier 0: Every minute (active/new feeds)
7
+ * - Tier 1: Every 2 minutes
8
+ * - Tier 2: Every 4 minutes
9
+ * - Tier 3: Every 8 minutes
10
+ * - Tier 4: Every 16 minutes
11
+ * - Tier 5: Every 32 minutes
12
+ * - Tier 6: Every 64 minutes (~1 hour)
13
+ * - Tier 7: Every 128 minutes (~2 hours)
14
+ * - Tier 8: Every 256 minutes (~4 hours)
15
+ * - Tier 9: Every 512 minutes (~8 hours)
16
+ * - Tier 10: Every 1024 minutes (~17 hours)
17
+ *
18
+ * @module polling/tier
19
+ */
20
+
21
+ const MIN_TIER = 0;
22
+ const MAX_TIER = 10;
23
+ const DEFAULT_TIER = 1;
24
+
25
+ /**
26
+ * Get polling interval for a tier in milliseconds
27
+ * @param {number} tier - Polling tier (0-10)
28
+ * @returns {number} Interval in milliseconds
29
+ */
30
+ export function getIntervalForTier(tier) {
31
+ const clampedTier = Math.max(MIN_TIER, Math.min(MAX_TIER, tier));
32
+ const minutes = Math.pow(2, clampedTier);
33
+ return minutes * 60 * 1000;
34
+ }
35
+
36
+ /**
37
+ * Get next fetch time based on tier
38
+ * @param {number} tier - Polling tier
39
+ * @returns {Date} Next fetch time
40
+ */
41
+ export function getNextFetchTime(tier) {
42
+ const interval = getIntervalForTier(tier);
43
+ return new Date(Date.now() + interval);
44
+ }
45
+
46
+ /**
47
+ * Calculate new tier after a fetch
48
+ * @param {object} options - Options
49
+ * @param {number} options.currentTier - Current tier
50
+ * @param {boolean} options.hasNewItems - Whether new items were found
51
+ * @param {number} options.consecutiveUnchanged - Consecutive fetches with no changes
52
+ * @returns {object} New tier and metadata
53
+ */
54
+ export function calculateNewTier(options) {
55
+ const {
56
+ currentTier = DEFAULT_TIER,
57
+ hasNewItems,
58
+ consecutiveUnchanged = 0,
59
+ } = options;
60
+
61
+ let newTier = currentTier;
62
+ let newConsecutiveUnchanged = consecutiveUnchanged;
63
+
64
+ if (hasNewItems) {
65
+ // Reset unchanged counter
66
+ newConsecutiveUnchanged = 0;
67
+
68
+ // Decrease tier (more frequent) if we found new items
69
+ if (currentTier > MIN_TIER) {
70
+ newTier = currentTier - 1;
71
+ }
72
+ } else {
73
+ // Increment unchanged counter
74
+ newConsecutiveUnchanged = consecutiveUnchanged + 1;
75
+
76
+ // Increase tier (less frequent) after consecutive unchanged fetches
77
+ // The threshold increases with tier to prevent thrashing
78
+ const threshold = Math.max(2, currentTier);
79
+ if (newConsecutiveUnchanged >= threshold && currentTier < MAX_TIER) {
80
+ newTier = currentTier + 1;
81
+ // Reset counter after tier change
82
+ newConsecutiveUnchanged = 0;
83
+ }
84
+ }
85
+
86
+ return {
87
+ tier: newTier,
88
+ consecutiveUnchanged: newConsecutiveUnchanged,
89
+ nextFetchAt: getNextFetchTime(newTier),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Get initial tier for a new feed subscription
95
+ * @returns {object} Initial tier settings
96
+ */
97
+ export function getInitialTier() {
98
+ return {
99
+ tier: MIN_TIER, // Start at tier 0 for immediate first fetch
100
+ consecutiveUnchanged: 0,
101
+ nextFetchAt: new Date(), // Fetch immediately
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Determine if a feed is due for fetching
107
+ * @param {object} feed - Feed document
108
+ * @returns {boolean} Whether the feed should be fetched
109
+ */
110
+ export function isDueForFetch(feed) {
111
+ if (!feed.nextFetchAt) {
112
+ return true;
113
+ }
114
+
115
+ return new Date(feed.nextFetchAt) <= new Date();
116
+ }
117
+
118
+ /**
119
+ * Get human-readable description of polling interval
120
+ * @param {number} tier - Polling tier
121
+ * @returns {string} Description
122
+ */
123
+ export function getTierDescription(tier) {
124
+ const minutes = Math.pow(2, tier);
125
+
126
+ if (minutes < 60) {
127
+ return `every ${minutes} minute${minutes === 1 ? "" : "s"}`;
128
+ }
129
+
130
+ const hours = minutes / 60;
131
+ if (hours < 24) {
132
+ return `every ${hours.toFixed(1)} hour${hours === 1 ? "" : "s"}`;
133
+ }
134
+
135
+ const days = hours / 24;
136
+ return `every ${days.toFixed(1)} day${days === 1 ? "" : "s"}`;
137
+ }
138
+
139
+ export { MIN_TIER, MAX_TIER, DEFAULT_TIER };