The Cavu Reader
cavu-page reader
3 folders · 16 files

README.md

Cavu Reader #

A TanStack Start app for reading books published on the AT Protocol via site.standard.publication + page.cavu.book (see ../DESIGN.md). No backend storage, no login — everything is fetched straight from the author's PDS with unauthenticated XRPC calls.

pnpm install
pnpm dev        # http://localhost:3000, redirects to the default book
pnpm typecheck
pnpm build

Routes #

Route Purpose
/ Discovers the newest book publication in the default repo (src/lib/defaultBook.ts) and redirects
/$did/$rkey Book overview: title page + table of contents
/$did/$rkey/read/$doc Reader; ?mode=scroll for infinite scroll (default paginated), ?at=end lands on the last page
/.well-known/site.standard.publication/$did/$rkey standard.site publication verification (path-suffixed "Non-root Publications" form)

standard.site discovery #

Reader pages emit <link rel="site.standard.document"> (chapters) and <link rel="site.standard.publication"> (overview, hint only). The well-known endpoint is the verification authority: it fetches the publication record fresh from its PDS and returns its AT-URI only if the record's url equals the canonical URL we'd serve it at — {READER_ROOT_URL}/{did}/{rkey} — so a shared reader domain never affirms books that don't claim to live here. Set READER_ROOT_URL to the deployment origin (e.g. https://reader.example); it falls back to the request origin in dev. The studio stamps the matching claim on every book it creates (assembleCreate's rootUrl).

KOReader sync (kosync) #

The server speaks KOReader's progress-sync API at /kosync so an e-reader's position can be written to the reader's own repo as page.cavu.progress. Because KOReader only ever sends a username and md5(password), the server holds its own confidential OAuth session per connected reader (scope repo:page.cavu.progress), obtained through "Connect KOReader" on the account page (/login), which also issues the sync password KOReader stores. Configuration:

  • SYNC_OAUTH_KEY — ES256 private JWK (JSON, with kid) for the confidential client. Generate with npx tsx scripts/gen-sync-key.ts and set it as a secret (fly secrets set SYNC_OAUTH_KEY="$(npx tsx scripts/gen-sync-key.ts)"). Unset → the account panel reports sync as not enabled. The client metadata lives at /oauth/sync-client-metadata.json (its client_id must be https, so the connect flow only runs against a deployed origin or a local stack with an http:// resolver).
  • HANDLE_RESOLVER_URL — handle → DID resolver (default https://public.api.bsky.app; the vivarium env points it at the local stack). VITE_HANDLE_RESOLVER_URL is the client-bundle twin.

Readers then set KOReader → Tools → Progress sync → Custom sync server to https://cavu.page/kosync and log in with their handle + the issued password (Register is refused — accounts come from the connect flow).

How a position gets from a file to a record: every EPUB build (server/export/epub.ts) is deterministic and registers its KOReader digests — the file's partial MD5 (KOReader's default "binary" matching) and md5(filename) — together with a spine map in kosync_documents (server/kosyncDocuments.ts). PUT /kosync/syncs/progress stores the raw push (kosync_progress, so any device gets it back verbatim) and, when the digest is one of ours, translates the crengine XPointer (/body/DocFragment[k]/body/section[s]/p[n]…) onto the book with lib/kosyncMap.ts, merges it into the reader's page.cavu.progress record with the same rules as the web reader (lib/progressRecord.ts) and writes it through the stored OAuth session (server/kosyncProgress.ts). Chunks the device moved past since its last push count as finished. GET /kosync/syncs/progress/:digest answers with whichever is newer — the raw push, or the repo record translated back into an XPointer (device "Cavu (web)"). Files pushed from KOReader that aren't Cavu EPUBs still sync between devices; they just don't reach the repo.

Send to Kindle #

Readers register their Kindle's Send-to-Kindle address (…@kindle.com) on the account page (/login); books whose author enabled downloads get a "Send to Kindle" button that mails the EPUB there (server/kindle.ts, tables kindle_devices / kindle_sends). Mail goes out through Comail over SMTP submission (server/kindleMail.ts — the HTTP Send API has no attachments). Configuration, all environment:

  • COMAIL_API_KEY — the atmos_… key; KINDLE_FROM_EMAIL — the sender, on a domain enrolled under the Comail DID. Either unset → the feature is hidden (button and account panel don't render; /api/kindle/send answers 503); sends already queued are held until it comes back.
  • COMAIL_SMTP_USER (default: the sender's domain), COMAIL_SMTP_HOST (smtp.atmos.email), COMAIL_SMTP_PORT (587, STARTTLS).

Readers must add KINDLE_FROM_EMAIL to their approved senders at amazon.com/sendtokindle (the account panel tells them the address). A send is attempted immediately; relay rate limits / network trouble leave it queued and a five-minute sweep retries on a backoff for three days, then marks it failed.

Email notifications #

Cavu emails a reader when a book they follow gains chapters, or an author they follow starts a new book. Email is a delivery channel on a follow, not a second subscription: the follows are records in the reader's own repo (site.standard.graph.subscription for a book, page.cavu.graph.authorSubscription for an author), mirrored into subscribers / author_subscribers so fan-out is one query. Signed-in readers only, and the address is confirmed before anything is sent to it — atproto has no email scope to borrow, and double opt-in is also what stops an account pointing our relay at somebody else's inbox.

Configuration, all environment (plus the shared COMAIL_* values above):

  • NOTIFY_FROM_EMAIL — the sender, e.g. notifications@cavu.page, on a domain enrolled under the Comail DID. Deliberately not the same address as KINDLE_FROM_EMAIL: readers hand-approve that one at Amazon, and sending bulk mail from it risks their Kindle delivery. Unset → the feature hides (account panel and per-book opt-in don't render; /api/notify/email answers 503). Author follows keep working: they are a record, not a mail feature.
  • NOTIFY_REPLY_TO — optional monitored address; omitted otherwise.

How a release becomes mail (server/notify.ts): the indexer already records spine growth in releases (server/shelfIndex.ts upsert), and now records a book from an already-indexed author in new_books. Both are written only from the live jetstream path — the live flag on upsert — because the startup backfill meets every book for the first time and would otherwise mail the whole catalogue to everyone. A one-minute sweep expands each event into one notify_sends row per recipient and mails the queue, backing off per attempt and giving up after three days. Every step is idempotent: jetstream delivery is at-least-once, and UNIQUE (kind, event_key, did) is what makes a replay free. A replace-chunk publish produces no publication event and so fires nothing — lib/publisher/diff.ts enforces that on the write side.

Every message carries RFC 8058 one-click unsubscribe headers scoped to the channel it came from, plus a link to /notify/unsubscribe/:token where the reader can adjust or restore either channel without signing in. /api/notify/unsubscribe is the only endpoint in the app that mutates without a session cookie; the token is the whole authorization, and the action can only turn mail off.

Bounces are only partly visible: SMTP submission surfaces synchronous refusals, which mark the address bounced and stop further sends, but asynchronous bounces go to the envelope sender and never reach us. Real bounce processing would need a return-path mailbox or webhook.

Verify the whole thing offline with npx tsx scripts/notify-probe.ts (add --from /path/to/cavu.db to test the migration against real data). It runs with no transport configured, so nothing can leave.

How reading works #

Data layer (src/lib/atproto.ts): DID → PDS resolution (plc.directory / did:web) and com.atproto.repo.getRecord fetches, both behind in-memory promise caches so loaders, prefetches, and the scroll window share one fetch per record. The reader keeps a three-record window — current, previous, and next spine documents — warm at all times.

Paginated mode (PaginatedReader): the chunk's blocks flow into viewport-width CSS columns; turning a page translates the column strip. Arrow keys / PageUp / PageDown / Space and edge click-zones page through; paging past either end of a chunk navigates to the neighboring spine document (?at=end when arriving backwards).

Comic spreads (ComicReader): chunks whose content is page.cavu.pages (pre-paginated image pages) always render as a two-page spread — the mode toggle disappears while reading them, and text and comic chunks can mix in one spine. lib/spreads.ts lays pages into left/right slots in reading order, honoring the book's pageProgression (rtl flips slot order and turn direction for manga), per-page spread hints (left/right pin a page to a side, leaving the other slot blank as print does; center takes the whole spread), and wider-than-tall pages, which get a spread to themselves. The same keys and click-zones as paginated mode turn pages — arrows follow the visual direction — neighboring spreads' images are preloaded, and edge turns hand off to neighboring spine documents. A comic chunk reached in scroll mode (mixed spine) renders as a plain vertical page column.

Scroll mode (ScrollReader): at most three spine documents are mounted at once. Crossing a chunk boundary shifts the window and syncs the URL (replace, no scroll reset). Sections filling in or dropping out above the viewport are compensated with a scroll anchor so the text never jumps. TOC links to chapters outside the window reset it.

Content rendering (Blocks.tsx) covers the block set in the wild: Leaflet text/header/blockquote/image/horizontalRule with byte-range facets, plus page.cavu.blocks.chapterTitle (the explicit chapter-start marker: chapter-opening treatment, a fresh page in paginated mode, a large gap in scroll mode — record boundaries stay invisible), page.cavu.blocks.verse (stanzas with hard line breaks), and page.cavu.blocks.toc (rendered as a linked contents nav). Unknown block types are skipped.

Lexicon types are imported type-only from the repo's generated code (../src/lexicons/types via the @lexicons/* path alias), so the reader bundles none of the validation machinery.