@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 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
+ ![iread UI](https://github.com/user-attachments/assets/ca9489c9-630f-4a67-9cd5-60c18ce3544e)
8
+
7
9
  ## Quick start
8
10
 
9
11
  ```sh
@@ -91,4 +91,3 @@ for (let i = 0; i < args.length; i++) {
91
91
  // Serve the prebuilt web bundle unless the caller explicitly set NODE_ENV.
92
92
  process.env.NODE_ENV ??= 'production';
93
93
  await import('./index.js');
94
- //# sourceMappingURL=cli.js.map
package/dist/server/db.js CHANGED
@@ -155,4 +155,3 @@ export function totalChanges() {
155
155
  const row = db.prepare('SELECT total_changes() AS n').get();
156
156
  return row.n;
157
157
  }
158
- //# sourceMappingURL=db.js.map
@@ -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 function refreshFeedRow(row) {
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
- // Parse + sanitize OUTSIDE the transaction (async, CPU work, no DB). Only the
229
- // synchronous DB writes (commit) run inside the transaction so node:sqlite
230
- // never awaits mid-transaction. CRITICAL (review fix #13): the new
231
- // etag/last_modified are persisted only here, after a fully successful
232
- // parse+upsert.
233
- const { feedMeta, commit } = await parseAndPrepare(row.feed_url, result.xml);
234
- const newItems = transaction(() => {
235
- const inserted = commit(row.id);
236
- db.prepare(UPDATE_FEED_ON_SUCCESS).run(feedMeta.title, feedMeta.siteUrl, feedMeta.description, result.etag, result.lastModified, now, row.id);
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
- const message = err instanceof Error ? err.message : String(err);
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
- /** Refresh every feed sequentially. Never fails because one feed errored;
261
- * tallies refreshed/failed/newItems and returns the full updated feed list. */
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
- // Sequential, one transaction per feed (commit per feed keeps each transaction
268
- // short on the synchronous DB; one failure never aborts the loop).
269
- for (const row of rows) {
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 refreshFeedRow(row);
272
- if (outcome.ok)
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
- // refreshFeedRow already records its own errors; this guards against any
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
@@ -80,4 +80,3 @@ export async function fetchFeed(input) {
80
80
  clearTimeout(timer);
81
81
  }
82
82
  }
83
- //# sourceMappingURL=fetch-feed.js.map
@@ -61,4 +61,3 @@ serve({ fetch: app.fetch, port: PORT }, (info) => {
61
61
  console.log(`[iread] listening on ${url}`);
62
62
  });
63
63
  export { app };
64
- //# sourceMappingURL=index.js.map
@@ -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
@@ -135,4 +135,3 @@ export function exportOpml(feeds) {
135
135
  lines.push('');
136
136
  return lines.join('\n');
137
137
  }
138
- //# sourceMappingURL=opml.js.map
@@ -65,4 +65,3 @@ feeds.delete('/:id', (c) => {
65
65
  return serviceErrorToResponse(c, err);
66
66
  }
67
67
  });
68
- //# sourceMappingURL=feeds.js.map
@@ -41,4 +41,3 @@ export function parseOptionalInt(raw) {
41
41
  const n = Number(raw);
42
42
  return Number.isFinite(n) ? Math.trunc(n) : undefined;
43
43
  }
44
- //# sourceMappingURL=helpers.js.map
@@ -92,4 +92,3 @@ items.patch('/:id', async (c) => {
92
92
  return serviceErrorToResponse(c, err);
93
93
  }
94
94
  });
95
- //# sourceMappingURL=items.js.map
@@ -47,4 +47,3 @@ opml.post('/', async (c) => {
47
47
  const body = result;
48
48
  return c.json(body, 200);
49
49
  });
50
- //# sourceMappingURL=opml.js.map
@@ -72,4 +72,3 @@ export function sanitizeArticleHtml(raw) {
72
72
  export function toPlainText(raw) {
73
73
  return sanitizeHtml(raw, { allowedTags: [], allowedAttributes: {} });
74
74
  }
75
- //# sourceMappingURL=sanitize.js.map
@@ -272,4 +272,3 @@ export async function fetchWithGuardedRedirects(initialUrl, init) {
272
272
  }
273
273
  throw new SsrfError(`Too many redirects (> ${MAX_REDIRECTS}).`);
274
274
  }
275
- //# sourceMappingURL=ssrf.js.map
@@ -2,4 +2,3 @@
2
2
  // Shared between the Hono server and the React client. Type-only (no runtime code).
3
3
  // Timestamps are Unix epoch milliseconds. Import everywhere with `import type`.
4
4
  export {};
5
- //# sourceMappingURL=types.js.map