The reader

Ebooks open in a reader inside the app, which remembers the place across devices and can follow along with the audiobook, turning pages and lighting each sentence as it is read aloud. It is also the part of the app that renders the most untrusted content, and three rounds of security review found a way for a book to run script before it was closed for good.

foliate-js, and why it is vendored

Rendering is done by foliate-js (MIT licensed), checked in under internal/webui/assets/vendor/. Writing a reader was considered and rejected for the same reason SoundStorm does not transcode video: the two hard parts, the paginator and the EPUB CFI code, are about 44KB and 13KB of careful work that already exists.

It is the only third-party code in the project, and it does not break the zero-dependency rule's intent. The files are checked in and pinned by content, embedded with go:embed, and fetched at no point during a build. The Go module still has no dependencies at all.

What is SoundStorm's own: the server unzips books (GET /api/book/resource), so no zip library runs in the browser; it remembers the reading position per person; and reader.js wraps foliate with the bar, settings, read-along and the security filters described below.

Two foliate behaviours cost time and are worth knowing. view.open(book) renders nothing by itself: view.init({ lastLocation }) must follow, or the reader is blank with no error anywhere. And its shadow root is closed, so a browser test cannot look inside it; tests watch the relocate event instead.

Position is a CFI, not a page number

Reflowable text has no page numbers worth keeping: change the font size and "page 47" is different words. So the place is an EPUB CFI - a path into the book's structure down to a character, such as epubcfi(/6/4!/4/2/1:0) - plus how far through the book it is, as a fraction for the progress bar. Both are saved to the server (/api/book/progress), keyed by person, source and book.

The server validates them: the fraction must be between 0 and 1 (a JSON number like 1e400 decodes to infinity without complaint), the CFI is capped at 2KB, positions are kept only for books that exist, and saves are rate limited. Without those limits any member could grow the state file - the one every sign-in rewrites - without bound.

The Apple TV has its own native reader and reads and writes the same CFI, so a book left on the TV opens at the same spot in a browser.

Swipe, and when the arrows stay

On a touch screen pages turn by swiping, and the two arrow buttons are hidden: two 56px arrows were 29% of a 390px-wide screen. This was measured rather than assumed: scripts/reader-swipe-check.js swipes the reader and watches the fraction on the relocate event, and swiping reached exactly the positions the arrows reach, three times out of three in both directions.

A relocate event firing is not a page turn - it fires on a resize too. The first version of that check counted events and passed a tap that moved nothing. The fraction has to change, and change back.

The exception matters. foliate's paginator handles touch; its fixed-layout renderer, used for comics and illustrated books, has no touch handling at all. So reader.js marks a fixed-layout book and the arrows stay for it. The rule is keyed on pointer: coarse, not screen width, because a narrow desktop window still has a mouse and cannot swipe.

Opening a book no longer stops the music or the audiobook - reading to something is the point. The mini-player floats over the reader, and the page is laid out above it so no line hides behind it. A film still stops, since it would play behind the book.

Book content is untrusted

An EPUB is a zip of web pages, and anyone with upload access to the ebook shelf can add one. A member could upload a book whose chapters contain script, then wait for the owner to open it. foliate renders each chapter in a blob: iframe, which shares SoundStorm's origin - so a script that ran there would run with the reader's session.

Several layers stand in the way:

Three rounds of the same attack

Each layer above exists because a review found a door the previous ones missed. It is a good illustration of why the project runs blind security reviews (see The review passes).

RoundThe doorThe fix
FirstA chapter with an absolute <script src="https://this-install/api/book/resource?...x.js">. foliate rewrites relative references to blob: (blocked), but leaves absolute ones alone - and an absolute reference to SoundStorm's own origin is 'self'. The script ran as the owner and reached the accounts API.The book resource endpoint answers only fetch() requests, and never emits a JavaScript type.
SecondThe cover endpoint served a book's declared cover with a type taken from its file name, so a cover named x.js came back as JavaScript.The guard moved to one central place for every stream path; a cover must be declared an image, and SVG is not accepted as one.
ThirdA chapter loading SoundStorm's real /static/app.js by absolute address - a genuine script that must stay loadable. It ran against the book's copy of the app's element ids, and an invisible label over a copied switch could turn on remote access at the owner's first tap.The reader strips every script from every chapter, and the app refuses to run in a frame.

Later passes widened the stripping: a chapter declared text/xml or with a charset parameter once skipped it, and chapters foliate hands over as a Blob are now read as text first. The CSP stops these on its own; the stripping means the reader does not have to rely on every web view passing the policy on.

Read-along: the page follows the audiobook

When somebody has the same book as an ebook and an audiobook, the Read Along pill in Books pairs them. Matching a recording to its text sentence by sentence is forced alignment - transcribe the audio, find it in the book - which is machine learning, so it is done by a backend, Storyteller. SoundStorm provisions it, hands it the pairs, and reads back two things: Storyteller's copy of the text, with every sentence wrapped in an element with an id, and a timeline converted to the audiobook's whole-book clock.

Crucially, the audio still plays through SoundStorm's own player, from Audiobookshelf. Storyteller's EPUB carries its own copy of the audio, and playing that would lose the lock screen, the mini-player and the listening position shared with Audiobookshelf's apps.

Four times a second the reader finds the sentence at the player's current time, turns to it with foliate's own navigation calls (the same ones its read-aloud uses) and lights it. Because it reads the player's own clock, it follows at any playback speed.

Keeping the screen on

While a book follows its audiobook nobody touches the phone, so it used to sleep mid-chapter. The reader holds a screen wake lock (navigator.wakeLock) from the moment it starts following until it stops, and asks again whenever the page becomes visible, since the browser drops the lock whenever the page is hidden. Wake Lock needs a secure context, so over plain http the screen sleeps as before. A separate setting can keep the screen on while Now Playing is open, or always.

Offline books

Ebooks, documents and Read Along pairs can be downloaded: both halves, plus a synced book's text (without its copy of the audio) and its timeline. They go into the same Cache API store as songs, keyed by the address the reader would ask for, and the reader reads a downloaded book from the device before asking the server. Because the reader is several JavaScript modules, the service worker serves any /static/ file network-first with a cached fallback, and the kept shell lists foliate's modules - the first offline test found the reader undefined without that.

PDFs: the browser's own viewer

PDFs are not rendered by foliate. They open in the browser's built-in viewer inside an iframe, which is why the page may be framed by SoundStorm's own pages (and not by anybody else's). PDFs are the one book resource not sandboxed, because Chrome will not render a PDF in a sandboxed document. The cost is that browser viewers do not expose a position, so a PDF keeps no reading place in the web app. There are no PDF covers either: extracting one means rendering page one, which needs a PDF renderer the project does not carry.