|
1 | 1 | const https = require("node:https"); |
2 | 2 | const ical = require("node-ical"); |
3 | 3 | const Log = require("logger"); |
4 | | -const NodeHelper = require("node_helper"); |
5 | 4 | const CalendarFetcherUtils = require("./calendarfetcherutils"); |
6 | 5 | const { getUserAgent } = require("#server_functions"); |
7 | | -const { scheduleTimer } = require("#module_functions"); |
| 6 | + |
| 7 | +const FIFTEEN_MINUTES = 15 * 60 * 1000; |
| 8 | +const THIRTY_MINUTES = 30 * 60 * 1000; |
| 9 | +const MAX_SERVER_BACKOFF = 3; |
8 | 10 |
|
9 | 11 | /** |
10 | | - * |
11 | | - * @param {string} url The url of the calendar to fetch |
12 | | - * @param {number} reloadInterval Time in ms the calendar is fetched again |
13 | | - * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. |
14 | | - * @param {number} maximumEntries The maximum number of events fetched. |
15 | | - * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. |
16 | | - * @param {object} auth The object containing options for authentication against the calendar. |
17 | | - * @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too |
18 | | - * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. |
| 12 | + * CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling |
19 | 13 | * @class |
20 | 14 | */ |
21 | | -const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { |
22 | | - let reloadTimer = null; |
23 | | - let events = []; |
| 15 | +class CalendarFetcher { |
24 | 16 |
|
25 | | - let fetchFailedCallback = function () {}; |
26 | | - let eventsReceivedCallback = function () {}; |
| 17 | + /** |
| 18 | + * Creates a new CalendarFetcher instance |
| 19 | + * @param {string} url - The URL of the calendar to fetch |
| 20 | + * @param {number} reloadInterval - Time in ms between fetches |
| 21 | + * @param {string[]} excludedEvents - Event titles to exclude |
| 22 | + * @param {number} maximumEntries - Maximum number of events to return |
| 23 | + * @param {number} maximumNumberOfDays - Maximum days in the future to fetch |
| 24 | + * @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass} |
| 25 | + * @param {boolean} includePastEvents - Whether to include past events |
| 26 | + * @param {boolean} selfSignedCert - Whether to accept self-signed certificates |
| 27 | + */ |
| 28 | + constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { |
| 29 | + this.url = url; |
| 30 | + this.reloadInterval = reloadInterval; |
| 31 | + this.excludedEvents = excludedEvents; |
| 32 | + this.maximumEntries = maximumEntries; |
| 33 | + this.maximumNumberOfDays = maximumNumberOfDays; |
| 34 | + this.auth = auth; |
| 35 | + this.includePastEvents = includePastEvents; |
| 36 | + this.selfSignedCert = selfSignedCert; |
| 37 | + |
| 38 | + this.events = []; |
| 39 | + this.reloadTimer = null; |
| 40 | + this.serverErrorCount = 0; |
| 41 | + this.fetchFailedCallback = () => {}; |
| 42 | + this.eventsReceivedCallback = () => {}; |
| 43 | + } |
27 | 44 |
|
28 | 45 | /** |
29 | | - * Initiates calendar fetch. |
| 46 | + * Clears any pending reload timer |
30 | 47 | */ |
31 | | - const fetchCalendar = () => { |
32 | | - clearTimeout(reloadTimer); |
33 | | - reloadTimer = null; |
34 | | - let httpsAgent = null; |
35 | | - let headers = { |
36 | | - "User-Agent": getUserAgent() |
37 | | - }; |
| 48 | + clearReloadTimer () { |
| 49 | + if (this.reloadTimer) { |
| 50 | + clearTimeout(this.reloadTimer); |
| 51 | + this.reloadTimer = null; |
| 52 | + } |
| 53 | + } |
38 | 54 |
|
39 | | - if (selfSignedCert) { |
40 | | - httpsAgent = new https.Agent({ |
41 | | - rejectUnauthorized: false |
42 | | - }); |
| 55 | + /** |
| 56 | + * Schedules the next fetch respecting MagicMirror test mode |
| 57 | + * @param {number} delay - Delay in milliseconds |
| 58 | + */ |
| 59 | + scheduleNextFetch (delay) { |
| 60 | + const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval); |
| 61 | + if (process.env.mmTestMode === "true") { |
| 62 | + return; |
43 | 63 | } |
44 | | - if (auth) { |
45 | | - if (auth.method === "bearer") { |
46 | | - headers.Authorization = `Bearer ${auth.pass}`; |
| 64 | + this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay); |
| 65 | + } |
| 66 | + |
| 67 | + /** |
| 68 | + * Builds the options object for fetch |
| 69 | + * @returns {object} Options object containing headers (and agent if needed) |
| 70 | + */ |
| 71 | + getRequestOptions () { |
| 72 | + const headers = { "User-Agent": getUserAgent() }; |
| 73 | + const options = { headers }; |
| 74 | + |
| 75 | + if (this.selfSignedCert) { |
| 76 | + options.agent = new https.Agent({ rejectUnauthorized: false }); |
| 77 | + } |
| 78 | + |
| 79 | + if (this.auth) { |
| 80 | + if (this.auth.method === "bearer") { |
| 81 | + headers.Authorization = `Bearer ${this.auth.pass}`; |
47 | 82 | } else { |
48 | | - headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`; |
| 83 | + headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`; |
49 | 84 | } |
50 | 85 | } |
51 | 86 |
|
52 | | - fetch(url, { headers: headers, agent: httpsAgent }) |
53 | | - .then(NodeHelper.checkFetchStatus) |
54 | | - .then((response) => response.text()) |
55 | | - .then((responseData) => { |
56 | | - let data = []; |
57 | | - |
58 | | - try { |
59 | | - data = ical.parseICS(responseData); |
60 | | - Log.debug(`parsed data=${JSON.stringify(data, null, 2)}`); |
61 | | - events = CalendarFetcherUtils.filterEvents(data, { |
62 | | - excludedEvents, |
63 | | - includePastEvents, |
64 | | - maximumEntries, |
65 | | - maximumNumberOfDays |
66 | | - }); |
67 | | - } catch (error) { |
68 | | - fetchFailedCallback(this, error); |
69 | | - scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); |
70 | | - return; |
71 | | - } |
72 | | - this.broadcastEvents(); |
73 | | - scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); |
74 | | - }) |
75 | | - .catch((error) => { |
76 | | - fetchFailedCallback(this, error); |
77 | | - scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); |
78 | | - }); |
79 | | - }; |
80 | | - |
81 | | - /* public methods */ |
| 87 | + return options; |
| 88 | + } |
82 | 89 |
|
83 | 90 | /** |
84 | | - * Initiate fetchCalendar(); |
| 91 | + * Parses the Retry-After header value |
| 92 | + * @param {string} retryAfter - The Retry-After header value |
| 93 | + * @returns {number|null} Milliseconds to wait or null if parsing failed |
85 | 94 | */ |
86 | | - this.startFetch = function () { |
87 | | - fetchCalendar(); |
88 | | - }; |
| 95 | + parseRetryAfter (retryAfter) { |
| 96 | + const seconds = Number(retryAfter); |
| 97 | + if (!Number.isNaN(seconds) && seconds >= 0) { |
| 98 | + return seconds * 1000; |
| 99 | + } |
| 100 | + |
| 101 | + const retryDate = Date.parse(retryAfter); |
| 102 | + if (!Number.isNaN(retryDate)) { |
| 103 | + return Math.max(0, retryDate - Date.now()); |
| 104 | + } |
| 105 | + |
| 106 | + return null; |
| 107 | + } |
89 | 108 |
|
90 | 109 | /** |
91 | | - * Broadcast the existing events. |
| 110 | + * Determines the retry delay for a non-ok response |
| 111 | + * @param {Response} response - The fetch Response object |
| 112 | + * @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay |
92 | 113 | */ |
93 | | - this.broadcastEvents = function () { |
94 | | - Log.info(`Fetcher: Broadcasting ${events.length} events from ${url}.`); |
95 | | - eventsReceivedCallback(this); |
96 | | - }; |
| 114 | + getDelayForResponse (response) { |
| 115 | + const { status, statusText = "" } = response; |
| 116 | + let delay = this.reloadInterval; |
| 117 | + |
| 118 | + if (status === 401 || status === 403) { |
| 119 | + delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES); |
| 120 | + Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`); |
| 121 | + } else if (status === 429) { |
| 122 | + const retryAfter = response.headers.get("retry-after"); |
| 123 | + const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null; |
| 124 | + delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); |
| 125 | + Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`); |
| 126 | + } else if (status >= 500) { |
| 127 | + this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF); |
| 128 | + delay = this.reloadInterval * Math.pow(2, this.serverErrorCount); |
| 129 | + Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`); |
| 130 | + } else if (status >= 400) { |
| 131 | + delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); |
| 132 | + Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`); |
| 133 | + } else { |
| 134 | + Log.error(`${this.url} - Unexpected HTTP status ${status}.`); |
| 135 | + } |
| 136 | + |
| 137 | + return { |
| 138 | + delay, |
| 139 | + error: new Error(`HTTP ${status} ${statusText}`.trim()) |
| 140 | + }; |
| 141 | + } |
97 | 142 |
|
98 | 143 | /** |
99 | | - * Sets the on success callback |
100 | | - * @param {eventsReceivedCallback} callback The on success callback. |
| 144 | + * Fetches and processes calendar data |
101 | 145 | */ |
102 | | - this.onReceive = function (callback) { |
103 | | - eventsReceivedCallback = callback; |
104 | | - }; |
| 146 | + async fetchCalendar () { |
| 147 | + this.clearReloadTimer(); |
| 148 | + |
| 149 | + let nextDelay = this.reloadInterval; |
| 150 | + try { |
| 151 | + const response = await fetch(this.url, this.getRequestOptions()); |
| 152 | + if (!response.ok) { |
| 153 | + const { delay, error } = this.getDelayForResponse(response); |
| 154 | + nextDelay = delay; |
| 155 | + this.fetchFailedCallback(this, error); |
| 156 | + } else { |
| 157 | + this.serverErrorCount = 0; |
| 158 | + const responseData = await response.text(); |
| 159 | + try { |
| 160 | + const parsed = ical.parseICS(responseData); |
| 161 | + Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`); |
| 162 | + this.events = CalendarFetcherUtils.filterEvents(parsed, { |
| 163 | + excludedEvents: this.excludedEvents, |
| 164 | + includePastEvents: this.includePastEvents, |
| 165 | + maximumEntries: this.maximumEntries, |
| 166 | + maximumNumberOfDays: this.maximumNumberOfDays |
| 167 | + }); |
| 168 | + this.broadcastEvents(); |
| 169 | + } catch (error) { |
| 170 | + Log.error(`${this.url} - iCal parsing failed: ${error.message}`); |
| 171 | + this.fetchFailedCallback(this, error); |
| 172 | + } |
| 173 | + } |
| 174 | + } catch (error) { |
| 175 | + Log.error(`${this.url} - Fetch failed: ${error.message}`); |
| 176 | + this.fetchFailedCallback(this, error); |
| 177 | + } |
| 178 | + |
| 179 | + this.scheduleNextFetch(nextDelay); |
| 180 | + } |
105 | 181 |
|
106 | 182 | /** |
107 | | - * Sets the on error callback |
108 | | - * @param {fetchFailedCallback} callback The on error callback. |
| 183 | + * Broadcasts the current events to listeners |
109 | 184 | */ |
110 | | - this.onError = function (callback) { |
111 | | - fetchFailedCallback = callback; |
112 | | - }; |
| 185 | + broadcastEvents () { |
| 186 | + Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`); |
| 187 | + this.eventsReceivedCallback(this); |
| 188 | + } |
113 | 189 |
|
114 | 190 | /** |
115 | | - * Returns the url of this fetcher. |
116 | | - * @returns {string} The url of this fetcher. |
| 191 | + * Sets the callback for successful event fetches |
| 192 | + * @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received |
117 | 193 | */ |
118 | | - this.url = function () { |
119 | | - return url; |
120 | | - }; |
| 194 | + onReceive (callback) { |
| 195 | + this.eventsReceivedCallback = callback; |
| 196 | + } |
121 | 197 |
|
122 | 198 | /** |
123 | | - * Returns current available events for this fetcher. |
124 | | - * @returns {object[]} The current available events for this fetcher. |
| 199 | + * Sets the callback for fetch failures |
| 200 | + * @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails |
125 | 201 | */ |
126 | | - this.events = function () { |
127 | | - return events; |
128 | | - }; |
129 | | -}; |
| 202 | + onError (callback) { |
| 203 | + this.fetchFailedCallback = callback; |
| 204 | + } |
| 205 | +} |
130 | 206 |
|
131 | 207 | module.exports = CalendarFetcher; |
0 commit comments