@isomoes/iread 0.2.1 → 0.2.3

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,10 @@ 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
+
9
+ 📺 Watch the [v0.2.2 intro video](https://www.bilibili.com/video/BV1cmJg6SEso/) on Bilibili.
10
+
7
11
  ## Quick start
8
12
 
9
13
  ```sh
@@ -26,6 +30,26 @@ Options:
26
30
  -h, --help Show this help and exit
27
31
  ```
28
32
 
33
+ ## Run with Docker
34
+
35
+ Prebuilt multi-arch images (amd64 + arm64) are published to the GitHub Container Registry, so no Node.js on the host is required:
36
+
37
+ ```sh
38
+ docker run -d --name iread -p 8787:8787 -v iread-data:/data ghcr.io/isomoes/iread:latest
39
+ ```
40
+
41
+ Then open http://localhost:8787. The SQLite database and the auto-saved `feeds.opml` mirror live under `/data` in the container — here the named `iread-data` volume — so your subscriptions survive container upgrades.
42
+
43
+ A `docker-compose.yml` is included for the same thing:
44
+
45
+ ```sh
46
+ docker compose up -d
47
+ ```
48
+
49
+ Build the image from the checkout instead of pulling it with `docker compose up -d --build`, or `docker build -t iread .`.
50
+
51
+ `PORT` (default 8787), `DB_PATH` (default `/data/iread.db`), and `OPML_PATH` (default `/data/feeds.opml`) are configurable via `-e`/`environment:`. To store data in a host directory instead of a named volume, bind-mount it and make it writable by the image's unprivileged `node` user (uid 1000), e.g. `mkdir iread-data && sudo chown 1000:1000 iread-data` then `-v "$PWD/iread-data:/data"`.
52
+
29
53
  ## Features
30
54
 
31
55
  - Add a feed by URL (title resolved from feed metadata), delete a feed.
@@ -41,18 +65,61 @@ Options:
41
65
  - OPML import (bulk add) and OPML export, plus an always-current `feeds.opml` auto-saved next to the database on every change for quick sharing and backup. The auto-saved file is a one-way snapshot of the database (overwritten on every change and at startup), so use it for backup and sharing — to bring feeds in, use OPML import.
42
66
  - Light, dark, and system theme, persisted to localStorage.
43
67
 
68
+ ## Usage
69
+
70
+ 1. Start the app (dev or production) and open it in your browser.
71
+ 2. Add a feed by pasting its RSS or Atom URL into the add-feed form in the sidebar, or import an OPML file from the OPML menu. A `config/sample-feeds.opml` file is included for a quick start, and [`isomoes/arch-config/iread/feeds.opml`](https://github.com/isomoes/arch-config/blob/master/iread/feeds.opml) is a real-world example (the feed set isomoes actually reads) you can import directly.
72
+ 3. Select a feed or a smart view (All, Unread, Starred) in the sidebar.
73
+ 4. Navigate the article list with `j` and `k`, open an article with `Enter`, and read it in the right pane.
74
+ 5. Press `r` to refresh the current feed or `R` to refresh all feeds. Refresh fetches remote feeds; client polling only refreshes local data and never re-fetches remotely.
75
+ 6. Star with `s`, toggle read with `m`, mark a whole scope read with `A`, and search with `/`.
76
+ 7. Press `?` at any time to see the full keyboard map. Press `t` to cycle the theme.
77
+
78
+ ## Keyboard shortcuts
79
+
80
+ Keys are case-sensitive (Shift matters). Bindings fire only when you are not typing in an input and no Ctrl, Meta, or Alt modifier is held.
81
+
82
+ | Key | Action | Effect |
83
+ | ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `j` / Down | Down in focused pane | Pane-contextual: move the article selection (list), the feed/view selection (sidebar), or the reader scroll down one. Stops at the end (no wrap). |
85
+ | `k` / Up | Up in focused pane | As `j`, upward. Stops at the start. |
86
+ | `n` | Next unread | Jump to the next unread item below; wrap to the first unread from the top if none below. |
87
+ | `g` | Top | Pane-contextual: in the list, select the first item; in the sidebar, jump to the first feed/view; in the reader, scroll the article to the top. |
88
+ | `G` | Bottom | Pane-contextual: in the list, select the last item; in the sidebar, jump to the last feed/view; in the reader, scroll the article to the bottom. |
89
+ | `Enter` / `o` | Open / focus reader | Render the selected item, mark it read, and move focus into the reader. |
90
+ | `J` / `]` | Next feed/view | Move sidebar selection down and load its items, selecting the first one. |
91
+ | `K` / `[` | Previous feed/view | Move sidebar selection up and load its items. |
92
+ | `m` | Toggle read/unread | Flip the read state of the selected item; counts update. |
93
+ | `s` | Toggle star | Flip the starred state; in the Starred view an unstarred item leaves the list. |
94
+ | `A` | Mark feed/view read | Mark the current scope read and offer an Undo toast. |
95
+ | `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
96
+ | `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
97
+ | `v` | Open original | Open the article link in a new tab. |
98
+ | `#` then N | Open link by number | Links in the article body are numbered inline `[N]`; press `#`, type the number, and it opens in a new tab — instantly once the number is unambiguous, otherwise `Enter` confirms; `Esc` cancels. |
99
+ | `/` | Focus search | Focus and select the search input. |
100
+ | `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
101
+ | `?` | Help overlay | Toggle the keybinding overlay. |
102
+ | `t` | Toggle theme | Cycle light, dark, and system theme; persisted. |
103
+
104
+ ## Project layout
105
+
106
+ - `src/shared/` shared, type-only DTOs used by both server and web.
107
+ - `src/server/` Hono API, SQLite access, feed fetch/parse/sanitize, OPML, SSRF guard.
108
+ - `src/web/` React app: three-pane layout, hooks, components, styles.
109
+ - `data/` development SQLite database (gitignored); production data lives in `~/.config/iread/`.
110
+
44
111
  ## Requirements (development)
45
112
 
46
113
  - Node.js 24 or newer (the server uses the built-in `node:sqlite` module).
47
114
  - pnpm.
48
115
 
49
- ## Install
116
+ ### Install
50
117
 
51
118
  ```sh
52
119
  pnpm install
53
120
  ```
54
121
 
55
- ## Develop
122
+ ### Develop
56
123
 
57
124
  ```sh
58
125
  pnpm dev
@@ -82,50 +149,3 @@ pnpm start
82
149
  Open http://localhost:8787
83
150
 
84
151
  `PORT` (default 8787), `DB_PATH` (default `~/.config/iread/iread.db`), and `OPML_PATH` (default `feeds.opml` next to the database; set empty to disable the auto-saved mirror) are read from the environment. See `config/.env.example`.
85
-
86
- ## Publish to npm
87
-
88
- `pnpm publish` runs the full build via the `prepack` hook and ships only `dist/` (server, shared types, and the prebuilt web bundle); the `iread` bin points at `dist/server/cli.js`. The web framework dependencies are devDependencies, so `npx @isomoes/iread` installs only the small server runtime set.
89
-
90
- ## Usage
91
-
92
- 1. Start the app (dev or production) and open it in your browser.
93
- 2. Add a feed by pasting its RSS or Atom URL into the add-feed form in the sidebar, or import an OPML file from the OPML menu. A `config/sample-feeds.opml` file is included for a quick start.
94
- 3. Select a feed or a smart view (All, Unread, Starred) in the sidebar.
95
- 4. Navigate the article list with `j` and `k`, open an article with `Enter`, and read it in the right pane.
96
- 5. Press `r` to refresh the current feed or `R` to refresh all feeds. Refresh fetches remote feeds; client polling only refreshes local data and never re-fetches remotely.
97
- 6. Star with `s`, toggle read with `m`, mark a whole scope read with `A`, and search with `/`.
98
- 7. Press `?` at any time to see the full keyboard map. Press `t` to cycle the theme.
99
-
100
- ## Keyboard shortcuts
101
-
102
- Keys are case-sensitive (Shift matters). Bindings fire only when you are not typing in an input and no Ctrl, Meta, or Alt modifier is held.
103
-
104
- | Key | Action | Effect |
105
- |---|---|---|
106
- | `j` / Down | Next article | Move selection down one row. Stops at the last item (no wrap). |
107
- | `k` / Up | Previous article | Move selection up one row. Stops at the first item. |
108
- | `n` | Next unread | Jump to the next unread item below; wrap to the first unread from the top if none below. |
109
- | `g` | Top | Select the first item and scroll to the top. |
110
- | `G` | Bottom | Select the last item and scroll to the bottom. |
111
- | `Enter` / `o` | Open / focus reader | Render the selected item, mark it read, and move focus into the reader. |
112
- | `J` / `]` | Next feed/view | Move sidebar selection down and load its items, selecting the first one. |
113
- | `K` / `[` | Previous feed/view | Move sidebar selection up and load its items. |
114
- | `m` | Toggle read/unread | Flip the read state of the selected item; counts update. |
115
- | `s` | Toggle star | Flip the starred state; in the Starred view an unstarred item leaves the list. |
116
- | `A` | Mark feed/view read | Mark the current scope read and offer an Undo toast. |
117
- | `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
118
- | `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
119
- | `v` | Open original | Open the article link in a new tab. |
120
- | `#` then N | Open link by number | Links in the article body are numbered inline `[N]`; press `#`, type the number, and it opens in a new tab — instantly once the number is unambiguous, otherwise `Enter` confirms; `Esc` cancels. |
121
- | `/` | Focus search | Focus and select the search input. |
122
- | `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
123
- | `?` | Help overlay | Toggle the keybinding overlay. |
124
- | `t` | Toggle theme | Cycle light, dark, and system theme; persisted. |
125
-
126
- ## Project layout
127
-
128
- - `src/shared/` shared, type-only DTOs used by both server and web.
129
- - `src/server/` Hono API, SQLite access, feed fetch/parse/sanitize, OPML, SSRF guard.
130
- - `src/web/` React app: three-pane layout, hooks, components, styles.
131
- - `data/` development SQLite database (gitignored); production data lives in `~/.config/iread/`.
@@ -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