From b1d052fc794c809352ce29d2f8d3090823392b43 Mon Sep 17 00:00:00 2001 From: nak0x Date: Sun, 6 Sep 2026 20:10:11 +0200 Subject: [PATCH] Make the reader the default route and document the toolset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch-all rule now sends anything that did not match a more specific rule to furst-serve, while `default` stays a plain browser. Because rules fall through when their command is missing, that arrangement needs no new mechanism: if furst-serve is not installed the reader rule is skipped and the browser still gets the URL. Firefox drops to a short list of sites that genuinely need it. Every reader page carries a browser link, so the list can stay short. Adds a README for furst-serve and rewrites the top-level one around the whole pipeline: what it is for, what each piece does, measured output sizes, and the limits — client-rendered pages, rate-limited search endpoints, DRM. --- README.md | 171 ++++++++++++++++++++---------------------- furst-serve/README.md | 104 +++++++++++++++++++++++++ src/config.rs | 37 ++++----- 3 files changed, 201 insertions(+), 111 deletions(-) create mode 100644 furst-serve/README.md diff --git a/README.md b/README.md index b220554..2bfa294 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,103 @@ # furst -A URL router. It sits where your default browser used to, and sends each URL to -the cheapest tool that can actually handle it — `mpv` for video, a pager for -PDFs, a light WebKit browser for reading, and Firefox only when nothing else -will do. +A toolset for browsing the modern web on hardware that cannot run a modern +browser. Built for a Core2 Duo with 4GB of RAM and a minimal Arch install, +where Firefox is the bottleneck. -Built for a Core2 Duo with 4GB of RAM, where the browser is the problem. - -## Why +## The idea A news page is 2–5MB across 80+ requests with 1–3MB of JavaScript to parse and -JIT. The same article extracted is ~20KB. Choosing a lighter *engine* buys -2–3×; not loading the payload at all buys 10–100×. `furst` is the dispatcher -that decides which of those you get, per URL. +JIT. The same article, extracted, is ~20KB. Choosing a lighter browser engine +buys 2–3×. **Not loading the payload at all buys considerably more.** + +So the goal is not a faster browser. It is to keep a browser engine out of the +loop wherever possible, and to make the cases where you still need one rare and +deliberate. + +``` + link click + │ + ┌────▼─────┐ video ──────────────► mpv (H.264 forced) + │ furst │ pdf / image ────────► zathura / nsxiv + │ router │ mailto / magnet ────► xdg-email / transmission + └────┬─────┘ a short "heavy" list ► firefox + │ + ▼ everything else + ┌──────────────┐ ┌──────────────┐ + │ furst-serve │───────►│ furst-read │ fetch → extract → render + │ local reader │ │ library │ + └──────┬───────┘ └──────────────┘ + │ minimal HTML, no JS/CSS/fonts, links rewritten to stay inside + ▼ + a light browser (surf, luakit, …) +``` + +## The pieces + +| | | +|---|---| +| [`furst`](src/) | the router. Sits where the default browser used to and dispatches each URL to the cheapest tool that can handle it. Two dependencies, 541K binary. | +| [`furst-read`](furst-read/README.md) | fetch, extract the article, render it as a self-contained document. Library and CLI. | +| [`furst-serve`](furst-serve/README.md) | the local reader. Rewrites in-page links so browsing stays in reader mode, caches pages, and handles what extraction cannot: search, feeds, listings, comment threads. | ## Install ```sh -cargo build --release -install -Dm755 target/release/furst ~/.local/bin/furst +cargo build --release --workspace +install -Dm755 target/release/furst target/release/furst-read \ + target/release/furst-serve -t ~/.local/bin/ -furst --init # writes ~/.config/furst/rules.toml, probes for a light browser -furst --install # registers furst as the system default browser +furst --init # rules tuned for low-end hardware +furst --install # become the system default browser +furst-serve --init # home page links and feeds + +sudo pacman -S mpv yt-dlp zathura zathura-pdf-mupdf nsxiv surf ``` -`--install` writes `~/.local/share/applications/furst.desktop` and points -`xdg-settings` at it, so every link click in every application routes here. +Then every link click in every application goes through the router, and most +of them never reach a browser engine. -## Use +## Measured + +| page | raw HTML | scripts | stylesheets | through furst | +|---|---|---|---|---| +| wiki.archlinux.org/title/Zram | 64K | 4 | 2 | 16K | +| en.wikipedia.org/wiki/Core_2 | 207K | 5 | 2 | 47K | +| theverge.com | 770K | 55 | 71 | 2.4K | +| lwn.net headlines (feed) | — | — | — | 11K, 15 entries | + +The HTML column understates it. The script and stylesheet columns are the real +story: those become zero, along with every webfont and tracker, which is where +an old CPU actually spends its time. A cached revisit is ~0.4ms. + +## Two details that matter on this hardware + +**Video is forced to H.264.** A Core2 handles 720p `avc1` in software but +stalls on VP9 and AV1, which is what YouTube serves by default. The format +string in the `video` rule is doing more work than the resolution cap, and +playing it in mpv skips the browser entirely. + +**The reader is the catch-all, not a special case.** Rules fall through when +their command is missing, so the last rule can be "send it to the reader" while +`default` stays a plain browser. Firefox is left for a short list of sites that +genuinely need it — and every reader page has a **browser** link for the rest. + +## Limits + +- Anything that renders client-side arrives empty. There is no JavaScript here; + that is the point, not an oversight. +- Free search endpoints rate-limit repeat visitors. The reader detects a + challenge page and tells you which engine refused, rather than showing an + empty list. See [furst-serve](furst-serve/README.md#on-search). +- Paywalls and DRM are not defeated, only rendered plainly or handed off. + +## Development ```sh -furst # match a rule and exec its command -furst --explain # show what would run, and why; run nothing -furst --list # show the loaded rules +cargo test --workspace # note: --workspace, the root package is just furst +cargo clippy --workspace --all-targets ``` -`--explain` is the one you want when a URL goes somewhere surprising: - -``` -$ furst --explain https://youtu.be/abc123 -scheme https -host youtu.be -path /abc123 - --> [video] mpv --ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+... https://youtu.be/abc123 - [default] surf https://youtu.be/abc123 -``` - -## Rules - -`~/.config/furst/rules.toml`. First matching rule wins. **If its command is -missing from `$PATH`, furst falls through to the next matching rule, and -finally to `default`** — which is what lets you name tools you have not written -yet and have the config stay working today. - -```toml -default = ["surf", "{url}"] - -[[rule]] -name = "video" -hosts = ["youtube.com", "youtu.be"] -run = ["mpv", "--ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+bestaudio/best", "{url}"] - -[[rule]] -name = "hn" -hosts = ["news.ycombinator.com"] -terminal = true # wrap in $TERMINAL -e -run = ["furst-hn", "{url}"] -``` - -A rule matches when every criterion it *states* is satisfied; a criterion is -satisfied by any one of its patterns. A rule that states nothing matches -everything. - -| Key | Matches against | -|---|---| -| `schemes` | `https`, `mailto`, `magnet`, … | -| `hosts` | `example.com` = apex **and** every subdomain; `*.example.com` = same; `=example.com` = that host exactly; `*` = any | -| `paths` | path component only, glob with `*`, case-insensitive | -| `contains` | substring of the whole raw URL | - -| Placeholder | | -|---|---| -| `{url}` `{url_enc}` | the URL, raw or percent-encoded | -| `{host}` `{path}` `{scheme}` | parsed components | - -If no argument mentions `{url}` or `{url_enc}`, the URL is appended last. - -## Notes for old hardware - -- **Force H.264 for video.** A Core2 handles 720p `avc1` in software but stalls - on VP9/AV1, which is what YouTube serves by default. That format string is - doing more work than the resolution cap. -- Host matching strips userinfo with `rfind('@')`, so - `https://bank.example@evil.example/` routes on `evil.example`. -- `furst` `exec`s the handler rather than forking, so it leaves no process - behind. - -## Companion tools - -- [`furst-read`](furst-read/README.md) — fetch a page, extract the article, and - render it as minimal HTML with no scripts, stylesheets, or webfonts. The - `reader` rule in the starter config points at it. - ## License MIT diff --git a/furst-serve/README.md b/furst-serve/README.md new file mode 100644 index 0000000..9d5ae8d --- /dev/null +++ b/furst-serve/README.md @@ -0,0 +1,104 @@ +# furst-serve + +A local reader server. It fetches pages, strips them to what you came for, and +serves them from `127.0.0.1` as small documents with no scripts, no +stylesheets, no webfonts and no third-party requests. + +The point is the link rewriting. Every link on a served page points back at +`/read?u=…`, so following one keeps you in the reader instead of dropping you +onto the live site. Combined with the disk cache, that is what makes browsing +on a Core2 feel immediate. + +## Use + +```sh +furst-serve # run in the foreground on 127.0.0.1:7714 +furst-serve --open # start it if needed, then open in a browser +furst-serve --status +furst-serve --init # write a starter home.toml +furst-serve --clear # drop the page cache +``` + +`--open` is what [furst](../README.md)'s catch-all rule calls. It starts a +detached server when nothing is listening, waits for the port, then execs +`$FURST_BROWSER`, `$BROWSER`, or the first light browser on `$PATH`. + +## Routes + +| | | +|---|---| +| `/` | home: pinned links and feeds, and the search box | +| `/read?u=` | the page — article, site view, or link index | +| `/search?q=` | search results | +| `/feed?u=` | an RSS or Atom feed | +| `/go?u=` | hand the original URL to the heavy browser | + +Add `&fresh=1` to `/read` or `/feed` to bypass the cache. Every page carries +**refresh**, **original** and **browser** links in the bar, so nothing is a +dead end. + +## What it does with a page + +1. **A feed** — rendered as a list of entries, whether it was requested at + `/feed` or just turned out to be XML. +2. **A site with an adapter** — Hacker News gets stories with score and + author, and comment threads with their indent preserved. +3. **An article** — extracted by [furst-read](../furst-read/README.md). +4. **Anything else** — if there is too little prose to be an article, the + page's links are listed instead. A front page has no article to find, and + saying so beats rendering an empty column. + +Non-documents (a PDF, an image) redirect to the original rather than being fed +to an article extractor. + +## Configuration + +`~/.config/furst/home.toml`: + +```toml +[[link]] +name = "Arch Wiki" +url = "https://wiki.archlinux.org/" + +[[feed]] +name = "LWN" +url = "https://lwn.net/headlines/newrss" + +[search] +url = "https://html.duckduckgo.com/html/?q={q}" +``` + +| Environment | | +|---|---| +| `FURST_PORT` | default 7714 | +| `FURST_BROWSER` / `BROWSER` | browser for `--open` | +| `FURST_HEAVY_BROWSER` | the `/go` target, default `firefox` | +| `XDG_CACHE_HOME` | pages land in `furst/pages` under it | + +## On search + +Search is engine-agnostic on purpose. **Every free HTML endpoint eventually +rate-limits a repeat visitor** — DuckDuckGo and searx.be both served this +machine a challenge page while it was being written. So: + +- Known result shapes (DuckDuckGo, SearXNG) are tried first, then heading + links, then any link. An unknown engine that serves plain HTML still works. +- A challenge page is detected from the body, not the status, because they + arrive as 200 or 202 rather than an error. You get a page saying which + engine refused and where to change it, never a silently empty result list. +- Blocked searches are not cached. + +If your engine rate-limits you, point `[search] url` at a SearXNG instance. + +Engines that render results client-side cannot work here — there is no +JavaScript, by design. + +## Design notes + +The HTTP layer is blocking, hand-rolled, GET only, one response per +connection, with a fixed pool of four worker threads. No async runtime: +predictable memory matters more than concurrency on a 4GB machine. Only the +loopback interface is ever bound. + +Cache entries carry a schema number, so changing the renderer drops them +rather than serving stale markup. diff --git a/src/config.rs b/src/config.rs index 1d66705..9b9945c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,7 +105,7 @@ pub const STARTER: &str = r##"# furst — route URLs to the cheapest tool that c # # First matching rule wins. If its command is missing from $PATH, furst falls # through to the next matching rule, and finally to `default`. That is what -# lets you name tools you have not written yet. +# lets the reader be the catch-all while a plain browser stays the safety net. # # Host patterns example.com the apex and every subdomain # *.example.com alias for the same @@ -116,6 +116,7 @@ pub const STARTER: &str = r##"# furst — route URLs to the cheapest tool that c # If no argument mentions {url} or {url_enc}, the URL is appended. # terminal=true wraps the command in $TERMINAL -e, for TUI handlers. +# Used when no rule matches, and when every matching rule's command is missing. default = ["surf", "{url}"] # ---------------------------------------------------------------- video --- @@ -146,28 +147,9 @@ name = "pdf" paths = ["*.pdf"] run = ["zathura", "{url}"] -# --------------------------------------------------------------- reading --- -# The 100x win. furst-read does not exist yet; until it does these fall -# through to `default` on their own. -[[rule]] -name = "reader" -hosts = [ - "wikipedia.org", "news.ycombinator.com", "lobste.rs", "medium.com", - "*.substack.com", "stackoverflow.com", "reddit.com", -] -run = ["furst-read", "{url}"] - -# ------------------------------------------------------------------ docs --- -[[rule]] -name = "docs" -hosts = [ - "docs.rs", "doc.rust-lang.org", "wiki.archlinux.org", "man7.org", - "github.io", "readthedocs.io", -] -run = ["surf", "{url}"] - # ----------------------------------------------------------------- heavy --- -# The escape hatch. Only these are allowed to cost you 400MB. +# The escape hatch. Only these are allowed to cost you 400MB. The reader also +# offers a "browser" link on every page, so this list can stay short. [[rule]] name = "heavy" hosts = [ @@ -176,7 +158,7 @@ hosts = [ ] run = ["firefox", "{url}"] -# ----------------------------------------------------------- non-web --- +# ---------------------------------------------------------------- non-web --- [[rule]] name = "mail" schemes = ["mailto"] @@ -186,4 +168,13 @@ run = ["xdg-email", "{url}"] name = "torrent" schemes = ["magnet"] run = ["transmission-remote", "-a", "{url}"] + +# ---------------------------------------------------------------- reader --- +# Everything else. This rule states no criteria, so it matches anything that +# got past the rules above. furst-serve starts the local reader if it is not +# already running, then opens the page in it. If furst-serve is not installed +# this falls through to `default` on its own. +[[rule]] +name = "reader" +run = ["furst-serve", "--open", "{url}"] "##;