Make the reader the default route and document the toolset

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.
This commit is contained in:
nak0x 2026-09-06 20:10:11 +02:00
parent 3f71d324c9
commit b1d052fc79
3 changed files with 201 additions and 111 deletions

171
README.md
View File

@ -1,108 +1,103 @@
# furst # furst
A URL router. It sits where your default browser used to, and sends each URL to A toolset for browsing the modern web on hardware that cannot run a modern
the cheapest tool that can actually handle it — `mpv` for video, a pager for browser. Built for a Core2 Duo with 4GB of RAM and a minimal Arch install,
PDFs, a light WebKit browser for reading, and Firefox only when nothing else where Firefox is the bottleneck.
will do.
Built for a Core2 Duo with 4GB of RAM, where the browser is the problem. ## The idea
## Why
A news page is 25MB across 80+ requests with 13MB of JavaScript to parse and A news page is 25MB across 80+ requests with 13MB of JavaScript to parse and
JIT. The same article extracted is ~20KB. Choosing a lighter *engine* buys JIT. The same article, extracted, is ~20KB. Choosing a lighter browser engine
23×; not loading the payload at all buys 10100×. `furst` is the dispatcher buys 23×. **Not loading the payload at all buys considerably more.**
that decides which of those you get, per URL.
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 ## Install
```sh ```sh
cargo build --release cargo build --release --workspace
install -Dm755 target/release/furst ~/.local/bin/furst 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 --init # rules tuned for low-end hardware
furst --install # registers furst as the system default browser 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 Then every link click in every application goes through the router, and most
`xdg-settings` at it, so every link click in every application routes here. 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 ```sh
furst <url> # match a rule and exec its command cargo test --workspace # note: --workspace, the root package is just furst
furst --explain <url> # show what would run, and why; run nothing cargo clippy --workspace --all-targets
furst --list # show the loaded rules
``` ```
`--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 ## License
MIT MIT

104
furst-serve/README.md Normal file
View File

@ -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 <url> # start it if needed, then open <url> 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.

View File

@ -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 # 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 # 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 # Host patterns example.com the apex and every subdomain
# *.example.com alias for the same # *.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. # If no argument mentions {url} or {url_enc}, the URL is appended.
# terminal=true wraps the command in $TERMINAL -e, for TUI handlers. # 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}"] default = ["surf", "{url}"]
# ---------------------------------------------------------------- video --- # ---------------------------------------------------------------- video ---
@ -146,28 +147,9 @@ name = "pdf"
paths = ["*.pdf"] paths = ["*.pdf"]
run = ["zathura", "{url}"] 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 --- # ----------------------------------------------------------------- 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]] [[rule]]
name = "heavy" name = "heavy"
hosts = [ hosts = [
@ -176,7 +158,7 @@ hosts = [
] ]
run = ["firefox", "{url}"] run = ["firefox", "{url}"]
# ----------------------------------------------------------- non-web --- # ---------------------------------------------------------------- non-web ---
[[rule]] [[rule]]
name = "mail" name = "mail"
schemes = ["mailto"] schemes = ["mailto"]
@ -186,4 +168,13 @@ run = ["xdg-email", "{url}"]
name = "torrent" name = "torrent"
schemes = ["magnet"] schemes = ["magnet"]
run = ["transmission-remote", "-a", "{url}"] 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}"]
"##; "##;