feedkeeper 0.0.0-stage → 0.23.0

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 (137) 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 +162 -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/app.js +100 -0
  20. package/server/dist/auth/avatar.js +13 -0
  21. package/server/dist/auth/capabilities.js +53 -0
  22. package/server/dist/auth/middleware.js +77 -0
  23. package/server/dist/auth/pairing.js +36 -0
  24. package/server/dist/auth/password.js +9 -0
  25. package/server/dist/auth/plan.js +58 -0
  26. package/server/dist/auth/security.js +117 -0
  27. package/server/dist/auth/securityRoutes.js +249 -0
  28. package/server/dist/auth/tokens.js +64 -0
  29. package/server/dist/auth/users.js +54 -0
  30. package/server/dist/backup-cli.js +101 -0
  31. package/server/dist/cli.js +135 -0
  32. package/server/dist/clientAddress.js +26 -0
  33. package/server/dist/config.js +51 -0
  34. package/server/dist/db/index.js +40 -0
  35. package/server/dist/db/migrations/0001_init.sql +70 -0
  36. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  37. package/server/dist/db/migrations/0003_retention.sql +13 -0
  38. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  39. package/server/dist/db/migrations/0005_folders.sql +15 -0
  40. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  41. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  42. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  43. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  44. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  45. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  46. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  47. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  48. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  49. package/server/dist/db/migrations/0015_push.sql +13 -0
  50. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  51. package/server/dist/db/migrations/0017_devices.sql +13 -0
  52. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  53. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  54. package/server/dist/db/migrations/0020_fts.sql +32 -0
  55. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  56. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  57. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  58. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  59. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  60. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  61. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  62. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  63. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  64. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  65. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  66. package/server/dist/feedkeeper.js +38 -0
  67. package/server/dist/feeds/archive.js +334 -0
  68. package/server/dist/feeds/articleContent.js +26 -0
  69. package/server/dist/feeds/cleanup.js +179 -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 +222 -0
  85. package/server/dist/feeds/repository.js +674 -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/index.js +15 -0
  91. package/server/dist/mcp/digest.js +73 -0
  92. package/server/dist/mcp/http.js +46 -0
  93. package/server/dist/mcp/items.js +73 -0
  94. package/server/dist/mcp/promptsAndResources.js +43 -0
  95. package/server/dist/mcp/server.js +547 -0
  96. package/server/dist/native/editionSelection.js +127 -0
  97. package/server/dist/native/editionText.js +15 -0
  98. package/server/dist/native/editions.js +218 -0
  99. package/server/dist/native/preferences.js +66 -0
  100. package/server/dist/native/requests.js +35 -0
  101. package/server/dist/oauth/constants.js +6 -0
  102. package/server/dist/oauth/host.js +8 -0
  103. package/server/dist/oauth/pages.js +148 -0
  104. package/server/dist/oauth/routes.js +361 -0
  105. package/server/dist/oauth/store.js +104 -0
  106. package/server/dist/push.js +147 -0
  107. package/server/dist/pushOutbox.js +164 -0
  108. package/server/dist/security-recover-cli.js +29 -0
  109. package/server/dist/server.js +108 -0
  110. package/server/dist/setup-cli.js +97 -0
  111. package/server/dist/sync/changeLog.js +170 -0
  112. package/server/dist/sync/mutations.js +144 -0
  113. package/server/dist/trustProxy.js +14 -0
  114. package/server/dist/version.js +6 -0
  115. package/server/package.json +6 -0
  116. package/web/dist/app-icon-180.png +0 -0
  117. package/web/dist/app-icon-192.png +0 -0
  118. package/web/dist/app-icon-512.png +0 -0
  119. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  120. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  121. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  122. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  124. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  126. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  128. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  130. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  131. package/web/dist/favicon-32.png +0 -0
  132. package/web/dist/index.html +40 -0
  133. package/web/dist/logo.svg +50 -0
  134. package/web/dist/manifest.webmanifest +64 -0
  135. package/web/dist/robots.txt +3 -0
  136. package/web/dist/sw.js +198 -0
  137. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,190 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { z } from "zod";
3
+ import { config } from "../config.js";
4
+ import { listSubscriptionsForUser, findFeedByUrl, createFeed, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
5
+ import { assertPublicHttpUrl, SsrfBlockedError } from "./ssrfGuard.js";
6
+ import { requestFeedPoll } from "./poller.js";
7
+ import { decodeEntities } from "./text.js";
8
+ function escapeXml(unsafe) {
9
+ return unsafe.replace(/[<>&'"]/g, (char) => {
10
+ switch (char) {
11
+ case "<":
12
+ return "&lt;";
13
+ case ">":
14
+ return "&gt;";
15
+ case "&":
16
+ return "&amp;";
17
+ case "'":
18
+ return "&apos;";
19
+ case '"':
20
+ return "&quot;";
21
+ default:
22
+ return char;
23
+ }
24
+ });
25
+ }
26
+ function feedToOutlineXml(s, indent = " ") {
27
+ const text = escapeXml(s.label || s.title || s.url);
28
+ const xmlUrl = escapeXml(s.url);
29
+ const htmlUrl = s.site_url ? ` htmlUrl="${escapeXml(s.site_url)}"` : "";
30
+ return `${indent}<outline text="${text}" title="${text}" type="rss" xmlUrl="${xmlUrl}"${htmlUrl} />`;
31
+ }
32
+ /**
33
+ * Generates standard OPML 2.0 XML representation of all subscribed feeds for a user.
34
+ */
35
+ export function generateOpml(userId) {
36
+ const subscriptions = listSubscriptionsForUser(userId).filter((s) => !s.is_system_inbox);
37
+ // Group feeds by folder
38
+ const unfiled = [];
39
+ const folders = new Map();
40
+ for (const sub of subscriptions) {
41
+ if (sub.folder_name) {
42
+ const list = folders.get(sub.folder_name) ?? [];
43
+ list.push(sub);
44
+ folders.set(sub.folder_name, list);
45
+ }
46
+ else {
47
+ unfiled.push(sub);
48
+ }
49
+ }
50
+ const sections = [];
51
+ // Add folder outlines
52
+ for (const [folderName, feeds] of folders.entries()) {
53
+ const escapedName = escapeXml(folderName);
54
+ const feedOutlines = feeds.map((f) => feedToOutlineXml(f, " ")).join("\n");
55
+ sections.push(` <outline text="${escapedName}" title="${escapedName}">\n${feedOutlines}\n </outline>`);
56
+ }
57
+ // Add unfiled feeds
58
+ for (const sub of unfiled) {
59
+ sections.push(feedToOutlineXml(sub, " "));
60
+ }
61
+ const outlines = sections.join("\n");
62
+ const now = new Date().toUTCString();
63
+ return `<?xml version="1.0" encoding="UTF-8"?>
64
+ <opml version="2.0">
65
+ <head>
66
+ <title>FeedKeeper Subscriptions</title>
67
+ <dateCreated>${now}</dateCreated>
68
+ </head>
69
+ <body>
70
+ ${outlines}
71
+ </body>
72
+ </opml>
73
+ `;
74
+ }
75
+ /**
76
+ * Recursively extracts outline elements containing an xmlUrl attribute from parsed OPML.
77
+ */
78
+ function extractOutlines(node, items = [], currentFolder) {
79
+ if (!node || typeof node !== "object")
80
+ return items;
81
+ if (Array.isArray(node)) {
82
+ for (const item of node) {
83
+ extractOutlines(item, items, currentFolder);
84
+ }
85
+ return items;
86
+ }
87
+ const record = node;
88
+ const xmlUrl = (typeof record["@_xmlUrl"] === "string" ? record["@_xmlUrl"] : undefined) ??
89
+ (typeof record["@_xmlurl"] === "string" ? record["@_xmlurl"] : undefined);
90
+ const rawLabel = (typeof record["@_text"] === "string" ? record["@_text"] : undefined) ??
91
+ (typeof record["@_title"] === "string" ? record["@_title"] : undefined);
92
+ if (xmlUrl) {
93
+ const label = rawLabel?.trim() ? decodeEntities(rawLabel.trim()) : undefined;
94
+ items.push({ url: xmlUrl.trim(), label, folder: currentFolder });
95
+ }
96
+ else if (rawLabel && record.outline) {
97
+ currentFolder = decodeEntities(rawLabel.trim());
98
+ }
99
+ if (record.outline) {
100
+ extractOutlines(record.outline, items, currentFolder);
101
+ }
102
+ return items;
103
+ }
104
+ /**
105
+ * Parses an OPML document string and returns a list of feed URLs and their labels.
106
+ */
107
+ export function parseOpml(xmlContent) {
108
+ const parser = new XMLParser({
109
+ ignoreAttributes: false,
110
+ attributeNamePrefix: "@_",
111
+ trimValues: true,
112
+ });
113
+ const parsed = parser.parse(xmlContent);
114
+ if (!parsed || !parsed.opml || !parsed.opml.body) {
115
+ throw new Error("invalid_opml_format");
116
+ }
117
+ return extractOutlines(parsed.opml.body.outline);
118
+ }
119
+ const urlSchema = z.string().url();
120
+ /**
121
+ * Imports feeds from an OPML document string for the given user.
122
+ */
123
+ export async function importOpmlFeeds(userId, xmlContent) {
124
+ const feedItems = parseOpml(xmlContent);
125
+ const result = {
126
+ imported: 0,
127
+ skipped: 0,
128
+ failed: 0,
129
+ errors: [],
130
+ };
131
+ const feedsToPoll = [];
132
+ const folderCache = new Map();
133
+ for (const item of feedItems) {
134
+ if (!item.url)
135
+ continue;
136
+ const urlCheck = urlSchema.safeParse(item.url);
137
+ if (!urlCheck.success) {
138
+ result.failed++;
139
+ result.errors.push({ url: item.url, reason: "invalid_url_format" });
140
+ continue;
141
+ }
142
+ let normalizedUrl;
143
+ try {
144
+ const validated = await assertPublicHttpUrl(item.url);
145
+ normalizedUrl = validated.toString();
146
+ }
147
+ catch (error) {
148
+ result.failed++;
149
+ const reason = error instanceof SsrfBlockedError ? error.message : "url_validation_failed";
150
+ result.errors.push({ url: item.url, reason });
151
+ continue;
152
+ }
153
+ try {
154
+ let feed = findFeedByUrl(normalizedUrl);
155
+ if (!feed) {
156
+ feed = createFeed(normalizedUrl, Math.max(config.minPollIntervalMinutes, 15));
157
+ }
158
+ if (isUserSubscribed(userId, feed.id)) {
159
+ result.skipped++;
160
+ continue;
161
+ }
162
+ let folderId = null;
163
+ if (item.folder) {
164
+ if (folderCache.has(item.folder)) {
165
+ folderId = folderCache.get(item.folder);
166
+ }
167
+ else {
168
+ const folder = createFolder(userId, item.folder);
169
+ folderId = folder.id;
170
+ folderCache.set(item.folder, folder.id);
171
+ }
172
+ }
173
+ subscribe(userId, feed.id, item.label ?? null, folderId);
174
+ result.imported++;
175
+ feedsToPoll.push(feed);
176
+ }
177
+ catch (error) {
178
+ result.failed++;
179
+ result.errors.push({
180
+ url: item.url,
181
+ reason: error instanceof Error ? error.message : String(error),
182
+ });
183
+ }
184
+ }
185
+ // Durable admission only: no detached serial loop or Promise per imported feed.
186
+ for (const feed of feedsToPoll)
187
+ requestFeedPoll(feed);
188
+ return result;
189
+ }
190
+ //# sourceMappingURL=opml.js.map
@@ -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