@isomoes/iread 0.2.1 → 0.2.2
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.
- package/README.md +2 -0
- package/dist/server/cli.js +0 -1
- package/dist/server/db.js +0 -1
- package/dist/server/feed-service.js +88 -33
- package/dist/server/fetch-feed.js +0 -1
- package/dist/server/index.js +0 -1
- package/dist/server/opml-sync.js +14 -2
- package/dist/server/opml.js +0 -1
- package/dist/server/routes/feeds.js +0 -1
- package/dist/server/routes/helpers.js +0 -1
- package/dist/server/routes/items.js +0 -1
- package/dist/server/routes/opml.js +0 -1
- package/dist/server/sanitize.js +0 -1
- package/dist/server/ssrf.js +0 -1
- package/dist/shared/types.js +0 -1
- package/dist/web/assets/{index-COIZwpno.js → index-BzVL9Rsl.js} +1 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/server/cli.js.map +0 -1
- package/dist/server/db.js.map +0 -1
- package/dist/server/feed-service.js.map +0 -1
- package/dist/server/fetch-feed.js.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/server/opml-sync.js.map +0 -1
- package/dist/server/opml.js.map +0 -1
- package/dist/server/routes/feeds.js.map +0 -1
- package/dist/server/routes/helpers.js.map +0 -1
- package/dist/server/routes/items.js.map +0 -1
- package/dist/server/routes/opml.js.map +0 -1
- package/dist/server/sanitize.js.map +0 -1
- package/dist/server/ssrf.js.map +0 -1
- package/dist/shared/types.js.map +0 -1
- package/dist/web/assets/index-COIZwpno.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A concise, local, single-user RSS/Atom reader inspired by newsboat. Keyboard-fir
|
|
|
4
4
|
|
|
5
5
|
There is no auth, no multi-user, no cloud sync, and no background scheduler. Refresh is always user-initiated. All data lives in a single local SQLite file — by default `~/.config/iread/iread.db` — which is the trust boundary.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
7
9
|
## Quick start
|
|
8
10
|
|
|
9
11
|
```sh
|
package/dist/server/cli.js
CHANGED
package/dist/server/db.js
CHANGED
|
@@ -212,7 +212,12 @@ const UPDATE_FEED_ON_SUCCESS = `
|
|
|
212
212
|
last_fetched_at = ?, fetch_error = NULL
|
|
213
213
|
WHERE id = ?
|
|
214
214
|
`;
|
|
215
|
-
async
|
|
215
|
+
// Phase 1 (async, no DB, runs concurrently across feeds): fetch + parse + sanitize.
|
|
216
|
+
// Never throws — a failed fetch/parse becomes an 'error' plan so the caller's loop
|
|
217
|
+
// always completes. CRITICAL (review fix #13): the new etag/last_modified are
|
|
218
|
+
// carried in the plan and persisted only by applyRefresh, after a fully
|
|
219
|
+
// successful parse+upsert.
|
|
220
|
+
async function planRefresh(row) {
|
|
216
221
|
const now = Date.now();
|
|
217
222
|
try {
|
|
218
223
|
const result = await fetchFeed({
|
|
@@ -220,30 +225,56 @@ async function refreshFeedRow(row) {
|
|
|
220
225
|
etag: row.etag,
|
|
221
226
|
lastModified: row.last_modified,
|
|
222
227
|
});
|
|
223
|
-
if (result.kind === 'notModified')
|
|
228
|
+
if (result.kind === 'notModified')
|
|
229
|
+
return { kind: 'notModified', now };
|
|
230
|
+
const { feedMeta, commit } = await parseAndPrepare(row.feed_url, result.xml);
|
|
231
|
+
return {
|
|
232
|
+
kind: 'ok',
|
|
233
|
+
now,
|
|
234
|
+
feedMeta,
|
|
235
|
+
commit,
|
|
236
|
+
etag: result.etag,
|
|
237
|
+
lastModified: result.lastModified,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
return { kind: 'error', now, message: err instanceof Error ? err.message : String(err) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Record a failed refresh on the feed row. Shared by the error-plan branch and the
|
|
245
|
+
// catch fallback so the UPDATE + error outcome live in exactly one place.
|
|
246
|
+
function recordFeedError(row, now, message) {
|
|
247
|
+
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
|
|
248
|
+
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
|
|
249
|
+
}
|
|
250
|
+
// Phase 2 (synchronous DB writes): apply one plan. Fully synchronous — it never
|
|
251
|
+
// awaits — so even when called from concurrent fetch workers its writes can't
|
|
252
|
+
// overlap and node:sqlite never awaits mid-transaction. Only the commit + feed-row
|
|
253
|
+
// update run inside the transaction.
|
|
254
|
+
function applyRefresh(row, plan) {
|
|
255
|
+
try {
|
|
256
|
+
if (plan.kind === 'notModified') {
|
|
224
257
|
// 304: nothing changed. Update last_fetched_at and clear any prior error.
|
|
225
|
-
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(now, row.id);
|
|
258
|
+
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(plan.now, row.id);
|
|
226
259
|
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: true };
|
|
227
260
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return inserted;
|
|
238
|
-
});
|
|
239
|
-
return { feed: getFeedWithCounts(row.id), newItems, ok: true };
|
|
261
|
+
if (plan.kind === 'ok') {
|
|
262
|
+
const newItems = transaction(() => {
|
|
263
|
+
const inserted = plan.commit(row.id);
|
|
264
|
+
db.prepare(UPDATE_FEED_ON_SUCCESS).run(plan.feedMeta.title, plan.feedMeta.siteUrl, plan.feedMeta.description, plan.etag, plan.lastModified, plan.now, row.id);
|
|
265
|
+
return inserted;
|
|
266
|
+
});
|
|
267
|
+
return { feed: getFeedWithCounts(row.id), newItems, ok: true };
|
|
268
|
+
}
|
|
269
|
+
return recordFeedError(row, plan.now, plan.message);
|
|
240
270
|
}
|
|
241
271
|
catch (err) {
|
|
242
|
-
|
|
243
|
-
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
|
|
244
|
-
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
|
|
272
|
+
return recordFeedError(row, plan.now, err instanceof Error ? err.message : String(err));
|
|
245
273
|
}
|
|
246
274
|
}
|
|
275
|
+
async function refreshFeedRow(row) {
|
|
276
|
+
return applyRefresh(row, await planRefresh(row));
|
|
277
|
+
}
|
|
247
278
|
function getFeedRow(id) {
|
|
248
279
|
return queryGet(`SELECT * FROM feeds WHERE id = ?`, id);
|
|
249
280
|
}
|
|
@@ -257,29 +288,54 @@ export async function refreshFeed(id) {
|
|
|
257
288
|
syncOpmlMirror();
|
|
258
289
|
return { feed: outcome.feed, newItems: outcome.newItems };
|
|
259
290
|
}
|
|
260
|
-
|
|
261
|
-
|
|
291
|
+
// Max feeds fetched+parsed at once in refreshAll. Bounded so we never open
|
|
292
|
+
// hundreds of sockets or buffer hundreds of 10 MB bodies simultaneously.
|
|
293
|
+
const REFRESH_CONCURRENCY = 8;
|
|
294
|
+
// Map items through an async worker with at most `limit` in flight, preserving
|
|
295
|
+
// input order in the result. Used to overlap the per-feed fetch/parse/apply work.
|
|
296
|
+
async function mapWithConcurrency(items, limit, worker) {
|
|
297
|
+
const results = new Array(items.length);
|
|
298
|
+
let next = 0;
|
|
299
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, () => (async () => {
|
|
300
|
+
for (;;) {
|
|
301
|
+
const i = next++;
|
|
302
|
+
if (i >= items.length)
|
|
303
|
+
return;
|
|
304
|
+
results[i] = await worker(items[i], i);
|
|
305
|
+
}
|
|
306
|
+
})());
|
|
307
|
+
await Promise.all(runners);
|
|
308
|
+
return results;
|
|
309
|
+
}
|
|
310
|
+
/** Refresh every feed. Each feed's fetch + parse runs concurrently (bounded by
|
|
311
|
+
* REFRESH_CONCURRENCY) and its synchronous apply runs in the same worker, so the
|
|
312
|
+
* DB writes serialize naturally and only ~concurrency feeds' parsed content is held
|
|
313
|
+
* at once. Never fails because one feed errored; tallies refreshed/failed/newItems
|
|
314
|
+
* and returns the full updated list. */
|
|
262
315
|
export async function refreshAll() {
|
|
263
316
|
const rows = queryAll(`SELECT * FROM feeds ORDER BY id ASC`);
|
|
264
317
|
let refreshed = 0;
|
|
265
318
|
let failed = 0;
|
|
266
319
|
let newItems = 0;
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
320
|
+
// Each worker fetches+parses (concurrent) then applies its own plan (synchronous,
|
|
321
|
+
// so applies never overlap and node:sqlite stays happy). The try/catch guarantees
|
|
322
|
+
// the batch always completes: even if applyRefresh's own error path throws (a
|
|
323
|
+
// DB-level fault), that feed is tallied as failed rather than aborting the request.
|
|
324
|
+
const outcomes = await mapWithConcurrency(rows, REFRESH_CONCURRENCY, async (row) => {
|
|
270
325
|
try {
|
|
271
|
-
const outcome = await
|
|
272
|
-
|
|
273
|
-
refreshed += 1;
|
|
274
|
-
else
|
|
275
|
-
failed += 1;
|
|
276
|
-
newItems += outcome.newItems;
|
|
326
|
+
const outcome = applyRefresh(row, await planRefresh(row));
|
|
327
|
+
return { ok: outcome.ok, newItems: outcome.newItems };
|
|
277
328
|
}
|
|
278
329
|
catch {
|
|
279
|
-
|
|
280
|
-
// unexpected throw so the loop always completes.
|
|
281
|
-
failed += 1;
|
|
330
|
+
return { ok: false, newItems: 0 };
|
|
282
331
|
}
|
|
332
|
+
});
|
|
333
|
+
for (const outcome of outcomes) {
|
|
334
|
+
if (outcome.ok)
|
|
335
|
+
refreshed += 1;
|
|
336
|
+
else
|
|
337
|
+
failed += 1;
|
|
338
|
+
newItems += outcome.newItems;
|
|
283
339
|
}
|
|
284
340
|
syncOpmlMirror();
|
|
285
341
|
return { feeds: listFeeds().feeds, refreshed, failed, newItems };
|
|
@@ -529,4 +585,3 @@ export function listFeedsForExport() {
|
|
|
529
585
|
export function syncOpmlMirror() {
|
|
530
586
|
writeOpmlSnapshot(listFeedsForExport());
|
|
531
587
|
}
|
|
532
|
-
//# sourceMappingURL=feed-service.js.map
|
package/dist/server/index.js
CHANGED
package/dist/server/opml-sync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { dbPath } from './db.js';
|
|
4
4
|
import { exportOpml } from './opml.js';
|
|
@@ -11,11 +11,24 @@ function resolveMirrorPath() {
|
|
|
11
11
|
return join(dirname(dbPath), 'feeds.opml');
|
|
12
12
|
}
|
|
13
13
|
export const opmlMirrorPath = resolveMirrorPath();
|
|
14
|
+
function withoutDateCreated(xml) {
|
|
15
|
+
return xml.replace(/[ \t]*<dateCreated>.*?<\/dateCreated>\r?\n?/, '');
|
|
16
|
+
}
|
|
14
17
|
export function writeOpmlSnapshot(feeds) {
|
|
15
18
|
if (!opmlMirrorPath)
|
|
16
19
|
return;
|
|
17
20
|
try {
|
|
18
21
|
const xml = exportOpml(feeds);
|
|
22
|
+
let existing = null;
|
|
23
|
+
try {
|
|
24
|
+
existing = readFileSync(opmlMirrorPath, 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
existing = null;
|
|
28
|
+
}
|
|
29
|
+
if (existing !== null && withoutDateCreated(existing) === withoutDateCreated(xml)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
19
32
|
mkdirSync(dirname(opmlMirrorPath), { recursive: true });
|
|
20
33
|
const tmp = `${opmlMirrorPath}.tmp`;
|
|
21
34
|
writeFileSync(tmp, xml, 'utf-8');
|
|
@@ -26,4 +39,3 @@ export function writeOpmlSnapshot(feeds) {
|
|
|
26
39
|
console.warn(`[iread] could not write OPML mirror at ${opmlMirrorPath}: ${message}`);
|
|
27
40
|
}
|
|
28
41
|
}
|
|
29
|
-
//# sourceMappingURL=opml-sync.js.map
|
package/dist/server/opml.js
CHANGED
package/dist/server/sanitize.js
CHANGED
package/dist/server/ssrf.js
CHANGED
package/dist/shared/types.js
CHANGED