feedkeeper 0.0.0-stage → 0.23.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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +167 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/auth/avatar.js +13 -0
  20. package/server/dist/auth/capabilities.js +53 -0
  21. package/server/dist/auth/middleware.js +77 -0
  22. package/server/dist/auth/pairing.js +36 -0
  23. package/server/dist/auth/password.js +21 -0
  24. package/server/dist/auth/security.js +126 -0
  25. package/server/dist/auth/securityRoutes.js +249 -0
  26. package/server/dist/auth/tokens.js +64 -0
  27. package/server/dist/auth/users.js +54 -0
  28. package/server/dist/backup-cli.js +101 -0
  29. package/server/dist/cli.js +135 -0
  30. package/server/dist/clientAddress.js +26 -0
  31. package/server/dist/config.js +51 -0
  32. package/server/dist/db/index.js +40 -0
  33. package/server/dist/db/migrations/0001_init.sql +70 -0
  34. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  35. package/server/dist/db/migrations/0003_retention.sql +13 -0
  36. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  37. package/server/dist/db/migrations/0005_folders.sql +15 -0
  38. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  39. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  40. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  41. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  42. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  43. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  44. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  45. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  46. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  47. package/server/dist/db/migrations/0015_push.sql +13 -0
  48. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  49. package/server/dist/db/migrations/0017_devices.sql +13 -0
  50. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  51. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  52. package/server/dist/db/migrations/0020_fts.sql +32 -0
  53. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  54. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  55. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  56. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  57. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  58. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  59. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  60. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  61. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  62. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  63. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  64. package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
  65. package/server/dist/feedkeeper.js +38 -0
  66. package/server/dist/feeds/archive.js +334 -0
  67. package/server/dist/feeds/articleContent.js +26 -0
  68. package/server/dist/feeds/cleanup.js +179 -0
  69. package/server/dist/feeds/dates.js +21 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +223 -0
  85. package/server/dist/feeds/repository.js +687 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/mcp/digest.js +73 -0
  91. package/server/dist/mcp/http.js +46 -0
  92. package/server/dist/mcp/items.js +73 -0
  93. package/server/dist/mcp/promptsAndResources.js +43 -0
  94. package/server/dist/mcp/server.js +547 -0
  95. package/server/dist/native/editionSelection.js +127 -0
  96. package/server/dist/native/editionText.js +15 -0
  97. package/server/dist/native/editions.js +218 -0
  98. package/server/dist/native/preferences.js +66 -0
  99. package/server/dist/native/requests.js +35 -0
  100. package/server/dist/oauth/constants.js +6 -0
  101. package/server/dist/oauth/host.js +8 -0
  102. package/server/dist/oauth/pages.js +148 -0
  103. package/server/dist/oauth/routes.js +370 -0
  104. package/server/dist/oauth/store.js +104 -0
  105. package/server/dist/push.js +147 -0
  106. package/server/dist/pushOutbox.js +164 -0
  107. package/server/dist/security-recover-cli.js +29 -0
  108. package/server/dist/server.js +108 -0
  109. package/server/dist/setup-cli.js +97 -0
  110. package/server/dist/sync/changeLog.js +170 -0
  111. package/server/dist/sync/mutations.js +144 -0
  112. package/server/dist/trustProxy.js +14 -0
  113. package/server/dist/version.js +6 -0
  114. package/server/package.json +6 -0
  115. package/web/dist/app-icon-180.png +0 -0
  116. package/web/dist/app-icon-192.png +0 -0
  117. package/web/dist/app-icon-512.png +0 -0
  118. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  119. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  120. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  121. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  122. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  124. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  126. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  128. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  130. package/web/dist/favicon-32.png +0 -0
  131. package/web/dist/index.html +40 -0
  132. package/web/dist/logo.svg +50 -0
  133. package/web/dist/manifest.webmanifest +64 -0
  134. package/web/dist/robots.txt +3 -0
  135. package/web/dist/sw.js +198 -0
  136. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,301 @@
1
+ import { AsyncResource } from "node:async_hooks";
2
+ import { setImmediate as yieldTurn } from "node:timers/promises";
3
+ import { db } from "../db/index.js";
4
+ import { findFeedById, updateFeedAfterPoll } from "./repository.js";
5
+ import { FeedHttpError } from "./fetcher.js";
6
+ import { feedRequests, PollDeferredError, PollShutdownError, RequestQueueFullError, sourceHost } from "./requestGate.js";
7
+ import { readPollSettings } from "./pollSettings.js";
8
+ import { pushOutboxMetrics } from "../pushOutbox.js";
9
+ // Created when the core is loaded at startup, outside HTTP middleware. Background attempts
10
+ // must not inherit a request's cached capabilities (or keep that request context alive).
11
+ const pollingContext = new AsyncResource("FeedKeeperPolling");
12
+ export function retryDelay(attempt, settings, random = Math.random()) {
13
+ return Math.ceil(Math.min(settings.retryMaxMs, settings.retryBaseMs * 2 ** attempt) * (0.5 + random * 0.5));
14
+ }
15
+ function isTransient(error) {
16
+ return error instanceof FeedHttpError ? [408, 425, 429, 500, 502, 503, 504].includes(error.status)
17
+ : error instanceof TypeError || error instanceof RequestQueueFullError || (error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name));
18
+ }
19
+ export function pollErrorCode(error) {
20
+ if (error instanceof FeedHttpError)
21
+ return `feed_http_${error.status}`;
22
+ if (error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name))
23
+ return "feed_timeout";
24
+ if (error instanceof TypeError)
25
+ return "feed_network_error";
26
+ return "feed_invalid_or_unavailable";
27
+ }
28
+ /** Install storage callbacks before discovery can run, without querying before migrations. */
29
+ export function configurePollHostCooldowns() {
30
+ feedRequests.cooldown = host => (db.prepare("SELECT retry_at FROM feed_poll_hosts WHERE host = ?").get(host)?.retry_at ?? 0);
31
+ feedRequests.defer = (host, until) => {
32
+ db.prepare("INSERT INTO feed_poll_hosts(host,retry_at) VALUES (?,?) ON CONFLICT(host) DO UPDATE SET retry_at = MAX(retry_at,excluded.retry_at)").run(host, until);
33
+ };
34
+ }
35
+ /** SQLite stores pending work; only active attempts and a capped set of API waiters live in memory.
36
+ * There is deliberately no durable running lock: restart simply replays unfinished jobs.
37
+ * Exactly one application process may own a database (not PM2 cluster / multiple replicas).
38
+ */
39
+ export class PollQueue {
40
+ attempt;
41
+ eligible;
42
+ settings;
43
+ active = new Map();
44
+ waiters = new Map();
45
+ stopped = false;
46
+ pumping = false;
47
+ repump = false;
48
+ seed;
49
+ timer;
50
+ logTimer;
51
+ errorCounts = {};
52
+ counters = { succeeded: 0, newItems: 0, completed: 0, errors: 0, retries: 0, throttled: 0, coalesced: 0, deferred: 0, durationMs: 0, maxDurationMs: 0, startLagMs: 0, maxStartLagMs: 0 };
53
+ since = Date.now();
54
+ constructor(attempt, eligible, settings = readPollSettings()) {
55
+ this.attempt = attempt;
56
+ this.eligible = eligible;
57
+ this.settings = settings;
58
+ configurePollHostCooldowns();
59
+ }
60
+ /** Preserve existing backoff; manual refresh never resets a retry budget or host cooldown. */
61
+ enqueue(feed) {
62
+ if (this.active.has(feed.id) || feed.is_system_inbox || !this.eligible(feed.id))
63
+ return;
64
+ const now = Date.now();
65
+ db.prepare("INSERT OR IGNORE INTO feed_poll_jobs(feed_id,host,enqueued_at,available_at) VALUES (?,?,?,?)")
66
+ .run(feed.id, sourceHost(feed.url), now, now);
67
+ }
68
+ submit(feed) {
69
+ if (this.stopped) {
70
+ // In-flight HTTP handlers may finish subscribing during drain. Preserve their request
71
+ // for the next process, even though this process will no longer dispatch work.
72
+ this.enqueue(feed);
73
+ return Promise.resolve({ newItems: 0, error: null, deferred: true });
74
+ }
75
+ const running = this.active.get(feed.id);
76
+ if (running) {
77
+ this.counters.coalesced++;
78
+ return running.promise;
79
+ }
80
+ const waiting = this.waiters.get(feed.id);
81
+ if (waiting) {
82
+ this.counters.coalesced++;
83
+ return waiting.promise;
84
+ }
85
+ this.enqueue(feed);
86
+ const job = db.prepare("SELECT * FROM feed_poll_jobs WHERE feed_id = ?").get(feed.id);
87
+ if (!job)
88
+ return Promise.resolve({ newItems: 0, error: "feed_poll_not_allowed" });
89
+ const retryAt = Math.max(job.available_at, feedRequests.cooldown(job.host));
90
+ if (retryAt > Date.now() || this.waiters.size >= this.settings.maxWaiters) {
91
+ this.counters.deferred++;
92
+ this.kick();
93
+ return Promise.resolve({ newItems: 0, error: null, deferred: true, retryAt });
94
+ }
95
+ let resolve;
96
+ const promise = new Promise(done => { resolve = done; });
97
+ const timer = setTimeout(() => {
98
+ this.waiters.delete(feed.id);
99
+ this.counters.deferred++;
100
+ resolve({ newItems: 0, error: null, deferred: true });
101
+ }, this.settings.attemptTimeoutMs);
102
+ this.waiters.set(feed.id, { promise, resolve, timer });
103
+ this.kick();
104
+ return promise;
105
+ }
106
+ /** Keyset pages create no Promise/task per feed, and yield so imports/sync remain responsive. */
107
+ seedDue() {
108
+ if (this.seed)
109
+ return this.seed;
110
+ this.seed = (async () => {
111
+ let after = 0;
112
+ while (!this.stopped) {
113
+ const rows = db.prepare(`SELECT f.* FROM feeds f
114
+ WHERE f.id > ? AND f.is_system_inbox = 0
115
+ AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id)
116
+ AND NOT EXISTS (SELECT 1 FROM feed_poll_jobs j WHERE j.feed_id = f.id)
117
+ AND (f.last_polled_at IS NULL OR f.last_polled_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now','-' || f.poll_interval_minutes || ' minutes'))
118
+ ORDER BY f.id LIMIT 200`).all(after);
119
+ if (!rows.length)
120
+ break;
121
+ db.transaction(() => { for (const feed of rows)
122
+ if (!this.active.has(feed.id))
123
+ this.enqueue(feed); })();
124
+ after = rows[rows.length - 1].id;
125
+ this.kick();
126
+ await yieldTurn();
127
+ }
128
+ this.kick();
129
+ })().finally(() => { this.seed = undefined; });
130
+ return this.seed;
131
+ }
132
+ start() {
133
+ if (this.timer || this.stopped)
134
+ return;
135
+ pollingContext.runInAsyncScope(() => {
136
+ this.timer = setInterval(() => this.kick(), this.settings.tickMs);
137
+ this.timer.unref();
138
+ this.logTimer = setInterval(() => {
139
+ try {
140
+ console.log(JSON.stringify({ event: "feed_poll_metrics", ...this.metrics() }));
141
+ db.prepare("DELETE FROM feed_poll_hosts WHERE retry_at <= ?").run(Date.now());
142
+ }
143
+ catch {
144
+ console.error(JSON.stringify({ event: "feed_poll_queue_error", code: "metrics_storage_failure" }));
145
+ }
146
+ }, 60_000);
147
+ this.logTimer.unref();
148
+ });
149
+ }
150
+ kick() {
151
+ if (this.stopped)
152
+ return;
153
+ if (this.pumping) {
154
+ this.repump = true;
155
+ return;
156
+ }
157
+ this.pumping = true;
158
+ // Schedule in a new event-loop turn, including after a synchronous DB/eligibility failure.
159
+ pollingContext.runInAsyncScope(() => setImmediate(() => {
160
+ this.repump = false;
161
+ try {
162
+ this.pump();
163
+ }
164
+ catch {
165
+ console.error(JSON.stringify({ event: "feed_poll_queue_error", code: "queue_storage_or_eligibility_failure" }));
166
+ }
167
+ finally {
168
+ this.pumping = false;
169
+ if (this.repump)
170
+ this.kick();
171
+ }
172
+ }));
173
+ }
174
+ pump() {
175
+ while (!this.stopped && this.active.size < this.settings.concurrency) {
176
+ const blockedHosts = [...new Set([...this.active.values()].map(entry => entry.host))]
177
+ .filter(host => [...this.active.values()].filter(entry => entry.host === host).length >= this.settings.hostConcurrency);
178
+ const job = db.prepare(`SELECT j.* FROM feed_poll_jobs j
179
+ WHERE j.available_at <= ?
180
+ AND j.feed_id NOT IN (SELECT value FROM json_each(?))
181
+ AND j.host NOT IN (SELECT value FROM json_each(?))
182
+ AND NOT EXISTS (SELECT 1 FROM feed_poll_hosts h WHERE h.host = j.host AND h.retry_at > ?)
183
+ ORDER BY j.enqueued_at, j.feed_id LIMIT 1`).get(Date.now(), JSON.stringify([...this.active.keys()]), JSON.stringify(blockedHosts), Date.now());
184
+ if (!job)
185
+ break;
186
+ const feed = findFeedById(job.feed_id);
187
+ if (!feed || feed.is_system_inbox || !this.eligible(feed.id)) {
188
+ db.prepare("DELETE FROM feed_poll_jobs WHERE feed_id = ?").run(job.feed_id);
189
+ this.resolve(job.feed_id, { newItems: 0, error: "feed_poll_not_allowed" });
190
+ continue;
191
+ }
192
+ // A URL may have been changed since enqueue. Do not dispatch under its old host budget.
193
+ if (sourceHost(feed.url) !== job.host) {
194
+ db.prepare("UPDATE feed_poll_jobs SET host = ? WHERE feed_id = ?").run(sourceHost(feed.url), feed.id);
195
+ continue;
196
+ }
197
+ const waiter = this.waiters.get(feed.id);
198
+ if (waiter)
199
+ clearTimeout(waiter.timer);
200
+ const controller = new AbortController();
201
+ const timeout = setTimeout(() => controller.abort(new DOMException("Poll deadline exceeded", "TimeoutError")), this.settings.attemptTimeoutMs);
202
+ const started = Date.now();
203
+ const dueAt = feed.last_polled_at ? Date.parse(feed.last_polled_at) + feed.poll_interval_minutes * 60_000 : Date.parse(feed.created_at);
204
+ const lag = Math.max(0, started - dueAt);
205
+ this.counters.startLagMs += lag;
206
+ this.counters.maxStartLagMs = Math.max(this.counters.maxStartLagMs, lag);
207
+ const promise = this.run(job, feed, controller.signal).catch(() => {
208
+ console.error(JSON.stringify({ event: "feed_poll_queue_error", code: "attempt_storage_failure" }));
209
+ // A storage failure leaves the durable job intact. Stop admission until process restart.
210
+ this.stopped = true;
211
+ return { newItems: 0, error: "feed_poll_storage_failure", deferred: true };
212
+ }).finally(() => {
213
+ clearTimeout(timeout);
214
+ const duration = Date.now() - started;
215
+ this.counters.durationMs += duration;
216
+ this.counters.maxDurationMs = Math.max(this.counters.maxDurationMs, duration);
217
+ this.counters.completed++;
218
+ this.active.delete(feed.id);
219
+ this.kick();
220
+ });
221
+ this.active.set(feed.id, { promise, controller, host: job.host });
222
+ void promise.then(result => this.resolve(feed.id, result));
223
+ }
224
+ }
225
+ async run(job, feed, signal) {
226
+ try {
227
+ const result = await this.attempt(feed, signal);
228
+ db.prepare("DELETE FROM feed_poll_jobs WHERE feed_id = ?").run(feed.id);
229
+ if (!result.error) {
230
+ this.counters.succeeded++;
231
+ this.counters.newItems += result.newItems;
232
+ }
233
+ return result;
234
+ }
235
+ catch (error) {
236
+ if (this.stopped || error instanceof PollShutdownError)
237
+ return { newItems: 0, error: null, deferred: true };
238
+ if (error instanceof PollDeferredError) {
239
+ this.counters.throttled++;
240
+ db.prepare("UPDATE feed_poll_jobs SET available_at = ? WHERE feed_id = ?").run(error.retryAt, feed.id);
241
+ return { newItems: 0, error: null, deferred: true, retryAt: error.retryAt };
242
+ }
243
+ this.counters.errors++;
244
+ if (error instanceof FeedHttpError && [429, 503].includes(error.status))
245
+ this.counters.throttled++;
246
+ const message = pollErrorCode(error);
247
+ this.errorCounts[message] = (this.errorCounts[message] ?? 0) + 1;
248
+ const transient = isTransient(error);
249
+ const retry = transient && job.attempts < this.settings.maxRetries;
250
+ const retryAt = Math.max(Date.now() + retryDelay(job.attempts, this.settings), error instanceof FeedHttpError ? error.retryAt ?? 0 : 0);
251
+ const resumeAt = retry ? retryAt : Math.max(retryAt, Date.now() + feed.poll_interval_minutes * 60_000);
252
+ db.transaction(() => {
253
+ updateFeedAfterPoll(feed.id, { error: message });
254
+ if (retry) {
255
+ // Rejoin the tail: a failing source cannot repeatedly displace fresh work.
256
+ db.prepare("UPDATE feed_poll_jobs SET attempts = attempts + 1, available_at = ?, enqueued_at = ? WHERE feed_id = ?").run(retryAt, Date.now(), feed.id);
257
+ }
258
+ else {
259
+ // Exhausted/permanent failures resume only on the next regular interval. Manual triggers
260
+ // join this durable delay instead of starting a fresh retry budget immediately.
261
+ db.prepare("UPDATE feed_poll_jobs SET attempts = 0, available_at = ?, enqueued_at = ? WHERE feed_id = ?")
262
+ .run(resumeAt, Date.now(), feed.id);
263
+ }
264
+ })();
265
+ if (retry)
266
+ this.counters.retries++;
267
+ return { newItems: 0, error: message, ...(transient ? { deferred: true, retryAt: resumeAt } : {}) };
268
+ }
269
+ }
270
+ resolve(id, result) {
271
+ const waiter = this.waiters.get(id);
272
+ if (!waiter)
273
+ return;
274
+ clearTimeout(waiter.timer);
275
+ this.waiters.delete(id);
276
+ waiter.resolve(result);
277
+ }
278
+ metrics() {
279
+ const now = Date.now();
280
+ const queue = db.prepare(`SELECT COUNT(*) pending,
281
+ COALESCE(SUM(available_at <= ?),0) due, COALESCE(MAX(? - enqueued_at),0) oldestMs FROM feed_poll_jobs`).get(now, now);
282
+ const { completed, ...counters } = this.counters;
283
+ return { ...queue, errorCounts: { ...this.errorCounts }, meanDurationMs: this.counters.durationMs / Math.max(1, completed), meanStartLagMs: this.counters.startLagMs / Math.max(1, completed), running: this.active.size, waiters: this.waiters.size, requests: feedRequests.stats, push: pushOutboxMetrics(),
284
+ completed, ...counters, elapsedMs: now - this.since,
285
+ throughputPerSecond: completed / Math.max(1, (now - this.since) / 1000) };
286
+ }
287
+ async stop() {
288
+ this.stopped = true;
289
+ if (this.timer)
290
+ clearInterval(this.timer);
291
+ if (this.logTimer)
292
+ clearInterval(this.logTimer);
293
+ for (const id of this.waiters.keys())
294
+ this.resolve(id, { newItems: 0, error: null, deferred: true });
295
+ for (const entry of this.active.values())
296
+ entry.controller.abort(new PollShutdownError());
297
+ await Promise.allSettled([...this.active.values()].map(entry => entry.promise));
298
+ await this.seed;
299
+ }
300
+ }
301
+ //# sourceMappingURL=pollQueue.js.map
@@ -0,0 +1,23 @@
1
+ /** Kept independent of application config so the guarded downloader also works in CLI tools. */
2
+ function integer(name, fallback, min, max) {
3
+ const raw = process.env[name];
4
+ const value = raw === undefined ? fallback : Number(raw);
5
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
6
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
7
+ }
8
+ return value;
9
+ }
10
+ export function readPollSettings() {
11
+ return {
12
+ concurrency: integer("POLL_CONCURRENCY", 4, 1, 32),
13
+ hostConcurrency: integer("POLL_HOST_CONCURRENCY", 2, 1, 8),
14
+ maxWaiters: integer("POLL_MAX_WAITERS", 64, 1, 1024),
15
+ requestTimeoutMs: integer("POLL_REQUEST_TIMEOUT_MS", 15_000, 50, 60_000),
16
+ attemptTimeoutMs: integer("POLL_ATTEMPT_TIMEOUT_MS", 30_000, 100, 120_000),
17
+ maxRetries: integer("POLL_MAX_RETRIES", 2, 0, 5),
18
+ retryBaseMs: integer("POLL_RETRY_BASE_MS", 5_000, 10, 300_000),
19
+ retryMaxMs: integer("POLL_RETRY_MAX_MS", 300_000, 100, 3_600_000),
20
+ tickMs: integer("POLL_TICK_MS", 1_000, 20, 60_000),
21
+ };
22
+ }
23
+ //# sourceMappingURL=pollSettings.js.map
@@ -0,0 +1,223 @@
1
+ import Parser from "rss-parser";
2
+ import { fetchFeed } from "./fetcher.js";
3
+ import { discoverIconUrls, iconCheckDue } from "./icon.js";
4
+ import { refreshFeedIcon } from "./feedIcon.js";
5
+ import { decodeEntities, plainTitle } from "./text.js";
6
+ import { enqueueNewItemNotifications, startPushOutbox, stopPushOutbox } from "../pushOutbox.js";
7
+ import { hasUserCapability } from "../auth/capabilities.js";
8
+ import { findFeedById, listSubscriberIds, updateFeedAfterPoll, updateFeedIcon, upsertItems } from "./repository.js";
9
+ import { db } from "../db/index.js";
10
+ import { configurePollHostCooldowns, PollQueue } from "./pollQueue.js";
11
+ import { feedRequests, PollDeferredError, sourceHost } from "./requestGate.js";
12
+ import { normalizePublishedAt } from "./dates.js";
13
+ configurePollHostCooldowns();
14
+ function mediaAttribute(value, key) {
15
+ if (typeof value !== "object" || value === null)
16
+ return undefined;
17
+ const record = value;
18
+ const attributes = record.$;
19
+ if (typeof attributes === "object" && attributes !== null) {
20
+ const nested = attributes[key];
21
+ if (nested !== undefined)
22
+ return nested;
23
+ }
24
+ return record[key];
25
+ }
26
+ const parser = new Parser({
27
+ customFields: {
28
+ item: [
29
+ ["media:content", "mediaContent", { keepArray: false }],
30
+ ["media:thumbnail", "mediaThumbnail", { keepArray: false }],
31
+ ["content:encoded", "contentEncoded", { keepArray: false }],
32
+ ],
33
+ },
34
+ });
35
+ function cleanImageUrl(url) {
36
+ return url.trim().replace(/&#038;/g, "&").replace(/&amp;/g, "&");
37
+ }
38
+ function isValidImageUrl(url) {
39
+ if (typeof url !== "string")
40
+ return false;
41
+ const trimmed = url.trim();
42
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://"))
43
+ return false;
44
+ if (trimmed.includes("/1x1") || trimmed.includes("pixel.wp.com") || trimmed.includes("tracking") || trimmed.includes("beacon")) {
45
+ if (trimmed.includes("pixel") || trimmed.includes("1x1"))
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+ function extractImageUrl(item) {
51
+ // 1. media:content (Media RSS)
52
+ const mediaContent = item.mediaContent ?? item["media:content"];
53
+ if (mediaContent) {
54
+ if (isValidImageUrl(mediaContent))
55
+ return cleanImageUrl(mediaContent);
56
+ const url = mediaAttribute(mediaContent, "url");
57
+ const medium = mediaAttribute(mediaContent, "medium");
58
+ const type = mediaAttribute(mediaContent, "type");
59
+ if (isValidImageUrl(url) && (medium === "image" || !medium || (typeof type === "string" && type.startsWith("image/")))) {
60
+ return cleanImageUrl(url);
61
+ }
62
+ }
63
+ // 2. media:thumbnail
64
+ const mediaThumbnail = item.mediaThumbnail ?? item["media:thumbnail"];
65
+ if (mediaThumbnail) {
66
+ if (isValidImageUrl(mediaThumbnail))
67
+ return cleanImageUrl(mediaThumbnail);
68
+ const url = mediaAttribute(mediaThumbnail, "url");
69
+ if (isValidImageUrl(url))
70
+ return cleanImageUrl(url);
71
+ }
72
+ // 3. enclosure
73
+ if (item.enclosure?.url) {
74
+ const url = item.enclosure.url;
75
+ const type = item.enclosure.type ?? "";
76
+ if (type.startsWith("image/") || /\.(jpe?g|png|webp|gif|avif|svg)(\?.*)?$/i.test(url)) {
77
+ if (isValidImageUrl(url))
78
+ return cleanImageUrl(url);
79
+ }
80
+ }
81
+ // 4. First <img> tag in HTML content or summary
82
+ const htmlContent = item.contentEncoded ?? item["content:encoded"] ?? item.content ?? item.summary ?? "";
83
+ if (typeof htmlContent === "string" && htmlContent.includes("<img")) {
84
+ const match = htmlContent.match(/<img[^>]+src=["'](https?:\/\/[^"'\s>]+)["']/i);
85
+ if (match && isValidImageUrl(match[1])) {
86
+ return cleanImageUrl(match[1]);
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ /** One attempt; the queue owns retries, admission, single-flight and the total deadline. */
92
+ async function pollAttempt(feed, signal) {
93
+ const fetched = await fetchFeed(feed.url, { etag: feed.etag ?? undefined, lastModified: feed.last_modified ?? undefined, signal });
94
+ const parsed = fetched.notModified ? null : await parser.parseString(fetched.body);
95
+ signal.throwIfAborted();
96
+ const current = findFeedById(feed.id);
97
+ if (!current || current.url !== feed.url)
98
+ throw new PollDeferredError(Date.now(), current ? sourceHost(current.url) : sourceHost(feed.url));
99
+ // Recheck hosted capabilities after network I/O, before committing or sending notifications.
100
+ if (!feedHasActiveSubscriber(feed.id))
101
+ return { newItems: 0, error: "feed_poll_not_allowed" };
102
+ const items = parsed?.items.map(item => ({
103
+ guid: item.guid ?? item.link ?? item.title ?? crypto.randomUUID(),
104
+ title: plainTitle(item.title), link: item.link,
105
+ contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
106
+ contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
107
+ publishedAt: normalizePublishedAt(item.isoDate ?? item.pubDate) ?? undefined, imageUrl: extractImageUrl(item),
108
+ }));
109
+ // No await in this transaction. Items, health/validators and job completion become visible together.
110
+ const newItems = db.transaction(() => {
111
+ const count = items ? upsertItems(feed.id, items) : 0;
112
+ updateFeedAfterPoll(feed.id, {
113
+ ...(parsed ? { title: plainTitle(parsed.title), siteUrl: parsed.link, etag: fetched.etag, lastModified: fetched.lastModified } : {}),
114
+ error: null,
115
+ });
116
+ if (count > 0 && feed.last_success_at)
117
+ enqueueNewItemNotifications(feed.id, count);
118
+ db.prepare("DELETE FROM feed_poll_jobs WHERE feed_id = ?").run(feed.id);
119
+ return count;
120
+ })();
121
+ // Push is committed above and dispatched independently of the feed deadline.
122
+ startPushOutbox();
123
+ // Bounded icon maintenance cannot retry a committed feed.
124
+ try {
125
+ signal.throwIfAborted();
126
+ if (iconCheckDue(feed.icon_checked_at)) {
127
+ const siteUrl = parsed?.link || feed.site_url;
128
+ const urls = siteUrl ? await discoverIconUrls(siteUrl, signal) : [];
129
+ signal.throwIfAborted();
130
+ updateFeedIcon(feed.id, feed.icon_url);
131
+ await refreshFeedIcon(feed.id, siteUrl, urls, signal);
132
+ }
133
+ }
134
+ catch { /* Keep the successful poll; icon failures are best effort. */ }
135
+ return { newItems, error: null };
136
+ }
137
+ let queue;
138
+ function pollingQueue() {
139
+ queue ??= new PollQueue(pollAttempt, feedHasActiveSubscriber);
140
+ queue.start();
141
+ startPushOutbox();
142
+ return queue;
143
+ }
144
+ /** Concurrent triggers share the same attempt result. Deferred work survives the HTTP caller. */
145
+ export function pollFeed(feed) { return pollingQueue().submit(feed); }
146
+ /** Bounded consumers for explicit refresh-all. Each feed still uses the shared durable queue. */
147
+ export async function pollFeeds(feeds) {
148
+ let cursor = 0;
149
+ let newItems = 0;
150
+ let errors = 0;
151
+ let deferred = 0;
152
+ await Promise.all(Array.from({ length: Math.min(pollingQueue().settings.concurrency, feeds.length) }, async () => {
153
+ while (cursor < feeds.length) {
154
+ const result = await pollFeed(feeds[cursor++]);
155
+ newItems += result.newItems;
156
+ if (result.error)
157
+ errors++;
158
+ if (result.deferred)
159
+ deferred++;
160
+ }
161
+ }));
162
+ return { newItems, errors, deferred };
163
+ }
164
+ /** Bulk imports need no suspended JavaScript task per subscription. */
165
+ export function requestFeedPoll(feed) {
166
+ const queue = pollingQueue();
167
+ queue.enqueue(feed);
168
+ queue.kick();
169
+ }
170
+ export function pollingMetrics() { return pollingQueue().metrics(); }
171
+ /** Fetch and parse a feed without storing anything, to validate a URL before switching to it. */
172
+ export async function probeFeed(url) {
173
+ const fetched = await fetchFeed(url);
174
+ const parsed = await parser.parseString(fetched.body);
175
+ return { title: parsed.title ?? null, itemCount: parsed.items.length };
176
+ }
177
+ /**
178
+ * Whether anyone who subscribes to the feed may have it updated. An account whose plan does not include syncing (a hosted
179
+ * service after the trial) gets no updates, and a feed that only such accounts subscribe to is not fetched at all.
180
+ */
181
+ export function feedHasActiveSubscriber(feedId) {
182
+ return listSubscriberIds(feedId).some((userId) => hasUserCapability(userId, "sync"));
183
+ }
184
+ /** Schedules due work, returning after bounded-page admission rather than a network round. */
185
+ export function pollDueFeeds() { return pollingQueue().seedDue(); }
186
+ let scheduler;
187
+ export function startPollingScheduler() {
188
+ if (scheduler)
189
+ return;
190
+ const tick = () => { void pollDueFeeds().catch(() => console.error(JSON.stringify({ event: "feed_poll_seed_error" }))); };
191
+ tick();
192
+ scheduler = setInterval(tick, 60_000);
193
+ scheduler.unref();
194
+ }
195
+ export async function stopPollingScheduler() {
196
+ if (scheduler)
197
+ clearInterval(scheduler);
198
+ scheduler = undefined;
199
+ const { stopImageCache } = await import("./archive.js");
200
+ await stopImageCache();
201
+ await Promise.all([queue?.stop(), stopPushOutbox()]);
202
+ feedRequests.stop();
203
+ }
204
+ /** Graceful shutdown for server integrations; process-manager grace should be >= 45 seconds. */
205
+ export function installPollingShutdown(server) {
206
+ let stopping = false;
207
+ const shutdown = () => {
208
+ if (stopping)
209
+ return;
210
+ stopping = true;
211
+ console.log(JSON.stringify({ event: "feed_poll_shutdown" }));
212
+ const deadline = setTimeout(() => process.exit(1), 40_000);
213
+ deadline.unref();
214
+ const drained = new Promise(resolve => server.close(() => resolve()));
215
+ void Promise.all([stopPollingScheduler(), drained]).then(() => {
216
+ server.closeAllConnections();
217
+ process.exit(0);
218
+ }, () => process.exit(1));
219
+ };
220
+ process.once("SIGTERM", shutdown);
221
+ process.once("SIGINT", shutdown);
222
+ }
223
+ //# sourceMappingURL=poller.js.map