- TypeScript 59.3%
- JavaScript 29.6%
- Rust 3.8%
- Lua 2.4%
- Swift 1.8%
- Other 3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| .localdata | ||
| app | ||
| assets | ||
| docs | ||
| extension | ||
| koreader | ||
| migrations | ||
| nix | ||
| scripts | ||
| src | ||
| test | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| railway.json | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
crosspoint-sync
Reading progress sync, stats and highlights for any KOSync-compatible e-reader, including KOReader and CrossPoint / CrossInk. Self-hostable, with a companion app. Syncs with 3rd party connectors such as Hardcover, Spotify, Audiobookshelf, BookOrbit, BookFusion, Readest, and Readwise.
Point your reader at it by changing only the sync server URL. The companion app (iOS, Android and web) shows your library, stats and clippings, and browses OPDS catalogs. On CrossPoint and CrossInk readers it also sends books, fonts, sleep screens and plugins over Wi-Fi.
- 100% KOSync-compatible. Point stock KOReader or current CrossPoint firmware at it by changing
only the sync-server URL. Same accounts, same auth, same endpoints as
sync.koreader.rocks. - Better multi-device sync. Progress is stored per device and the newest position wins, fixing the ping-pong you get with stock kosync servers.
- Server-side service connectors. Link services such as Hardcover, Micro.blog, Audiobookshelf, Jellyfin, BookOrbit, Readwise Reader, Seekquel, and Readest once; readers continue speaking standard KOSync while the server updates external reading state.
- BookOrbit on demand. Reader progress requests fetch the matched book’s latest progress and highlights. On a book’s page, Connected services → BookOrbit → Sync now pulls before queueing outbound updates and refreshes that book’s reading history. BookOrbit has no periodic polling; reading-session uploads trigger delivery directly.
- Lossless CrossPoint sync. An extended API carries the full CrossPoint position (spine, paragraph, anchor, page hints), not just a lossy xpath + percentage.
- Bookmarks, clippings, and reading stats. Delta sync with tombstones for bookmarks and clippings; per-device reading-stats snapshots with a server-side combined view (streaks included).
- KOReader stats plugin. Reading history uploads after normal KOSync progress pushes. See installation and setup.
- One codebase, one runtime. A single Node + SQLite server in one Docker image. The hosted
service and self-hosted installs run the exact same thing. No native dependencies (uses Node's
built-in
node:sqlite).
The full wire contract is in docs/API.md.
Run it
Docker
Published images support linux/amd64 and linux/arm64 (including Raspberry Pi
running a 64-bit OS). Docker automatically selects the matching architecture.
docker run -d --name crosspoint-sync \
-p 8080:8080 \
-v crosspoint-data:/data \
-e TOKEN_ENC_KEY=<output of openssl rand -hex 32> \
ghcr.io/crosspoint-reader/crosspoint-sync:main
TOKEN_ENC_KEY is optional. Without it, sync works but linking services (Hardcover,
Micro.blog, etc.) is disabled. Generate it once with openssl rand -hex 32 and keep it:
changing it later makes saved service logins unreadable, and they must be linked again.
Or from a checkout: docker compose up -d uses the published image from
docker-compose.yml.
For local image builds from the checkout, use:
docker compose -f docker-compose.dev.yml up -d --build
For localhost testing with connector credentials, Docker can forward requests
from a bridge address instead of a loopback address. Put your persistent
TOKEN_ENC_KEY in .env, then run:
HOST_BIND=127.0.0.1 HOST_PORT=8081 ALLOW_HTTP_LOCALHOST=true \
docker compose -f docker-compose.dev.yml up -d --build
Open http://localhost:8081/app/. This opt-in allows credential entry over HTTP
only for localhost URLs; keep the host port bound to 127.0.0.1. For docker run,
use -p 127.0.0.1:8081:8080 -e ALLOW_HTTP_LOCALHOST=true and your existing encryption
key and data volume. Public/self-hosted network deployments should use HTTPS.
To serve under a subpath, pass -e BASE_PATH=/crosspoint-sync to docker run,
or run BASE_PATH=/crosspoint-sync docker compose up -d. The same image works
at any prefix; no frontend rebuild is needed.
HTTPS reverse proxy (Caddy, nginx, etc.)
When the proxy handles HTTPS and forwards HTTP to Sync, set TRUST_PROXY=true
on the Sync server. Without it, linking BookOrbit or another connector fails
with "Connector credentials require HTTPS", even when the browser uses HTTPS.
This check concerns the connection to Sync, regardless of the BookOrbit server URL.
For Caddy running directly on the Docker host, replace the existing published port
in docker-compose.yml and add TRUST_PROXY to the service's existing environment:
services:
crosspoint-sync:
ports:
- "127.0.0.1:8080:8080"
environment:
TRUST_PROXY: "true"
Keep the service's other settings, including TOKEN_ENC_KEY for connector storage.
Apply the changes with docker compose up -d so the container is recreated with
the new environment. Adding TRUST_PROXY=true to a Compose .env file alone
does not pass it into the container; it must appear in the service's environment.
For bare Node, set it in the server process's environment and restart the process.
A minimal Caddyfile for that host setup is:
sync.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy sets X-Forwarded-Proto automatically.
Other proxies must also overwrite it with the original request's scheme. Only enable
proxy trust when clients cannot bypass the proxy and reach Sync directly. If Caddy
runs in Docker, use a shared private Docker network and proxy to crosspoint-sync:8080
without publishing Sync's port, instead of using the host loopback example above.
Railway (hosted-style deploy)
- New project → Deploy from this repo (Railway detects the Dockerfile).
- Attach a volume mounted at
/data. - That's it: the server listens on Railway's
PORTautomatically.
NixOS
Add this repository as a flake input and add the module to your configuration:
{
crosspoint-sync.url = "github:rogierknoester/crosspoint-sync";
crosspoint-sync.inputs.nixpkgs.follows = "nixpkgs";
...
}: {
nixosConfigurations = {
myServer = nixpkgs.lib.nixosSystem {
...
modules = [
./configuration.nix
crosspoint-sync.nixosModules.crosspoint-sync
];
};
};
}
Now you can enable it in your configuration.nix:
services.crosspoint-sync = {
enable = true;
port = 8080;
registration = true;
# Optional: serve at /crosspoint-sync/ instead of /.
basePath = "/crosspoint-sync";
};
Bare Node (≥ 22.13)
npm ci && npm run build && npm run build:frontend
DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js
Configuration
| Env var | Default | Meaning |
|---|---|---|
PORT |
8080 |
Listen port |
LISTEN_ADDRESS |
localhost (:: in the Docker image) |
Interface to listen on. :: listens on all of them, which a container needs to be reachable. |
BASE_PATH |
(empty) | URL prefix, e.g. /crosspoint-sync. Empty or / serves at the domain root; trailing slashes are normalized. Applies to the website, API, and health endpoint (/crosspoint-sync/healthz in this example). Set at runtime; no frontend rebuild needed. Reverse proxies must preserve the prefix, not strip it. |
DATABASE_PATH |
/data/crosspoint.db |
SQLite file (parent dirs auto-created) |
REGISTRATION_DISABLED |
false |
Set true to lock down a private instance |
AUTH_RATE_LIMIT_PER_MINUTE |
30 |
Per-IP limit on registration (0 disables) |
TOKEN_ENC_KEY |
(unset) | Enables external-service connectors. 64 hex chars, a base64 32-byte key, or a ≥32-char passphrase. Encrypts stored connector credentials at rest; unset = connectors disabled. |
ALLOW_HTTP_LOCALHOST |
false |
Local Docker testing only: allow connector credentials over HTTP at localhost, 127.0.0.1, or [::1] when Docker forwards from a bridge peer. Bind the published port to 127.0.0.1; leave disabled on network-facing deployments. |
TRUST_PROXY |
false (true on Railway) |
Set true only when direct access is blocked and a trusted reverse proxy overwrites any client-supplied X-Forwarded-Proto; permits connector linking through an HTTPS-terminating proxy. Defaults to true when RAILWAY_ENVIRONMENT is present, since Railway always fronts the service with its TLS-terminating edge; set TRUST_PROXY=false to override. |
GOOGLE_BOOKS_API_KEY |
(unset) | Optional. Fallback source for print page counts when Open Library has none (keyless Google Books has no quota). |
HARDCOVER_API_KEY |
(unset) | Optional. A Hardcover API key (read:catalog is enough) for book details from Hardcover's catalog: moods, genres, content warnings, rating, series, release year, and page counts/covers where other sources miss. One lookup per distinct book, cached for all users, throttled under Hardcover's free-plan limits. |
LEGACY_WEB |
(unset) | The website is the CrossPoint Sync app at /app/ (the old account pages redirect there; Kindle setup keeps its pages). Set 1 to keep the old server-rendered website instead. |
HARDCOVER_CLIENT_ID |
CrossPoint Sync's app | Optional. Hardcover OAuth client id for "Connect Hardcover" sign-in. The built-in id is CrossPoint Sync's public Hardcover app and works for self-hosted servers too; set this only to use your own Hardcover developer app. |
SEARCHAPI_KEY |
(unset) | Optional, paid (searchapi.io). Last fallback for print page counts: Amazon search + product "Print length", up to 2 credits per book. Only stats views ask (never library covers), only for books the free sources miss, once per book across all users; a miss is retried after 90 days. Pauses for an hour when SearchAPI refuses (out of credits, rate limited). |
SEARCHAPI_DAILY_LIMIT |
(unset) | Optional. Most SearchAPI credits to spend per UTC day; unset means no cap. Usage is counted either way. |
SEEKQUEL_URL |
https://api.seekquel.app |
Optional. The Seekquel API the Seekquel connector links to and syncs with. Only for testing against a local Seekquel. |
READEST_URL |
https://web.readest.com |
Optional. The Readest web origin the Readest connector links to and syncs with. Only for testing against a local Readest. |
READEST_SUPABASE_URL / READEST_SUPABASE_ANON_KEY |
Readest's hosted values | Optional. The Supabase project the Readest connector refreshes its sign-in with. Only for testing against a local Readest. |
CORS_ORIGINS |
* |
Origins allowed to call the sync API from browsers (comma-separated). The default wildcard is safe: the API authenticates with headers, not cookies, and the web UI's cookie routes never get CORS headers. |
With BASE_PATH=/crosspoint-sync, open https://your-host/crosspoint-sync/
and configure readers with https://your-host/crosspoint-sync as their server
URL. OAuth providers must allow the prefixed callback URL shown in Settings.
Native app association files, when enabled, remain at domain-root /.well-known/.
Link Micro.blog
- Sign in to Micro.blog.
- Open Account → App tokens.
- Create a separate app token for CrossPoint Sync.
- In CrossPoint Sync, open your account, choose Micro.blog, and paste the new token.
Treat the token like a password: Micro.blog app tokens have full account access. CrossPoint Sync
encrypts the token at rest using TOKEN_ENC_KEY.
With Micro.blog linked, each clipping on the Clippings page has an Add clipping to Micro.blog action. Selected clippings are collected into one post per book, titled 📚 Clippings from {book title}, with a link to the matched Micro.blog book page. Later clippings update the same post. CrossPoint Sync manages its generated content, so later updates replace edits made directly to that content on Micro.blog.
Choose Post clippings to in Micro.blog settings when the token accesses multiple blogs. A sole available blog is selected automatically. The choice applies to new collections: existing posts and new clippings for those books stay on the book's original blog. Revoked access pauses posting to that blog; selecting a different one never moves existing collections automatically.
Automatically sync clippings is off by default. Enabling it includes existing and future clippings. Unmatched books wait without interrupting reader sync; after matching, eligible clippings are sent on the next reader sync or Sync now. Turning automatic sync off stops automatic additions while retaining published passages and manually selected clippings.
Deleting a post on Micro.blog pauses publication for that book while preserving its clippings locally. After the missing post is detected on an update, the Micro.blog publishing paused section on the Clippings page offers Recreate clippings post, even if the book has no live local clippings. This explicitly republishes the collected clippings to either the previous blog (the default) or the current clippings blog selected in Settings. Future updates for this book follow the replacement post. If the previous blog is accessible, Sync rechecks that the old post is still missing; a restored post can be resumed on the previous blog instead. A previously confirmed deletion can also be recreated on the current blog after access to the previous blog is lost. Network or permission errors do not trigger automatic recreation.
Merging books that both have Micro.blog publication history pauses their combined collection. Settings shows the conflict as a sync error so you can review the existing posts before publishing further clippings.
For local testing, use Node 22.13 or newer, build the frontend with
npm run build:frontend, and copy .env.example to .env. Set a separate
DATABASE_PATH (such as ./data/microblog-test.db), PORT=8081, and a persistent
TOKEN_ENC_KEY generated with openssl rand -hex 32. Run npm run dev and open
the local app. Create a local account, sync a book
and its clippings from a reader, link Micro.blog, and match the book before trying
the clipping action or automatic sync. These actions publish to the linked blog.
Point your reader at it
- KOReader: Tools → Progress sync → Custom sync server →
http://your-host:8080 - CrossPoint / CrossInk: Settings → KOReader Sync → Sync Server URL
Create an account from the device (register via the sync settings), or:
curl -X POST http://localhost:8080/users/create \
-H 'content-type: application/json' \
-d '{"username":"justin","password":"'"$(printf '%s' 'my-password' | md5sum | cut -d' ' -f1)"'"}'
(The password field is the MD5 of your password (that's the kosync protocol); the server stores
a salted PBKDF2 of it, never the raw value.)
Development
npm ci
npm run dev # tsx watch, http://localhost:8080 (set DATABASE_PATH=./data/dev.db)
npm test # vitest: kosync compat suite + v1 API suite
scripts/curl-smoke.sh http://localhost:8080 # end-to-end smoke against a running server
License
MIT
Merged-book clipping compatibility
Clipping uploads to a merged book's old hash are stored under its canonical hash, matching progress sync. Reads by either hash include clipping data left under aliases by older server versions; no database rows are deleted or rewritten during reads. Clipping IDs appear once, with any deletion marker taking precedence; live duplicates use the newest revision and retain existing canonical annotations when an old alias upload omitted them. The clipping hub returns canonical hashes.
Clients that cached separate alias clipping cursors should reread merged books
with cursor=0 once after upgrading to recover older records they have not seen.
New delta reads use the family's highest revision for each clipping ID.
Per-book stats retain the existing selective merge policy: uploads remain under the original document hash, and only aliases merged with stats contribute to the canonical book's totals. Unmerging restores the original independent stats.
Daily reading duration
PUT /api/v1/stats/global accepts an optional daily array (up to 20 unique
local dates per request), e.g. [{"date":"2026-10-01","seconds":61}], alongside
the existing global snapshot. Seconds are whole cumulative device-local
reading seconds for that calendar date; minutes are derived as seconds / 60.
Valid dates are 2000–2099; durations are integers from 0 through 4294967295.
The response includes accepted_daily, including 0 for legacy payloads.
Migration 0014_daily_reading.sql adds stats_device_day without modifying old
snapshots or generating historical days. Each counter identity stores its maximum received counter per date;
the per-device view adds independent counter lifetimes. Repeats, stale requests, and missing dates in partial
batches cannot add time twice or erase it. Devices must send only their own local
counters with a stable ID; copying a device's history to another identity would
count it twice. Concurrent reading on two devices counts each device's active
time. Counters are monotonic history, independent of local global-total resets.
Reset-aware CrossInk firmware assigns fresh counter IDs after a reset or card
replacement, so new reading adds to previous cloud history. Repeated uploads
cannot duplicate or decrease a counter set's history. Update the server before
the firmware; newer firmware retains pending stats when an older server lacks
the protected write endpoints. Older firmware's smaller totals are preserved
at their previous values because a reset cannot be distinguished from a stale
retry. See the stats API for migration details.
GET /api/v1/stats/global includes stats.daily for each device.
GET /api/v1/stats/summary includes date-sorted daily rows with date, summed
seconds, and fractional minutes. The reader has already applied its timezone;
these dates are never converted using the server or browser timezone. Absent daily
history means unknown, not historical zero. The reading calendar preserves
progress-derived page estimates alongside recorded daily time, including when
CrossInk uploads only read-day flags. One calendar shows both measurements
together in each day's tooltip. Shading uses page counts when available, otherwise
recorded time; days without either metric retain a generic read marker.
Share cards keep page and time totals separate. Durations are never inferred
from read-day flags.
Account data deletion removes the new data; book merges/unmerges/deletion do not change device/day counters.
Regression checks: npm test, npm run build; after building the CrossInk
simulator, node test/daily-firmware-smoke.mjs /absolute/path/to/crossink/.pio/build/simulator/program
checks manual upload, shared sender/retries/increments, and an old server without
a daily acknowledgment using only disposable loopback fixtures.
KOReader reading statistics plugin
The plugin uploads reading time, daily activity and page history automatically after a successful normal KOSync progress upload. Configure it once; there is no need to open its menu for everyday syncing. Duplicate uploads are counted once.
Install and configure
- Update your CrossPoint Sync server to a version with the
/api/v1/stats/koreaderendpoint. Installing the plugin does not update the server. - Download
crosspoint-koreader.zipfrom a KOReader plugin release on the GitHub releases page. To build from this checkout instead, runnpm ci && npm run build:koreader; the ZIP is written todist/crosspoint-koreader.zip. - Extract the ZIP into KOReader's
pluginsfolder so the resulting path iskoreader/plugins/crosspoint.koplugin/main.lua. Restart KOReader and enable CrossPoint Sync in plugin management if needed. - Configure KOReader's built-in Progress sync with your CrossPoint server URL and sync credentials. Use binary/content checksum matching so statistics and reading positions refer to the same books.
- Open CrossPoint Sync → Set up server and account once. Enter the
same HTTPS server URL, sync username and password, plus your reading time
zone, such as
America/New_York,Europe/London, orUTC. - Use normal Progress sync → Push progress. After it succeeds, the plugin uploads stats in the background. KOSync's automatic pushes trigger stats too.
The first automatic upload includes historical reading for all books. Later uploads skip books whose history has already been acknowledged. Pull progress alone does not trigger stats. The plugin does not reconnect Wi-Fi after a suspend or disconnect; unsent records retry on the next successful progress push while online. A stats failure does not turn a successful progress push into a failure.
Use CrossPoint Sync → Last sync result to check the outcome, or Sync all reading history now to force a full backfill/retry. Stats failures also show a brief notice. Reading positions stay under normal KOSync.
The plugin also uploads highlights/clippings, their notes, and bookmarks from the currently opened book after the same successful progress push. Use Sync all highlights and bookmarks to backfill saved annotations from books retained in KOReader reading history, without opening each book. Missing or unreadable metadata is skipped and reported. The backfill uploads additions and edits only. Edits and deletions of previously uploaded annotations follow on later pushes; missing or unopened books are not treated as deleted. This is upload-only: server annotations are not downloaded into KOReader. Highlights appear in the app and use any configured highlight connectors. Bookmark locations are available through the authenticated server API for future firmware support; firmware integration is not included here.
Use the CrossPoint Sync for KOReader 0.2.9 release for annotation sync, with server v0.2.0
or newer. The original koreader-v0.1.0 release contains statistics sync only.
See the plugin guide for history-import migration, limits, HTTPS, and credential storage. Manual database imports remain available under app Settings → Import reading history.
Publish a plugin release
Install Node dependencies, Lua (or provide lua on PATH), and the
GitHub CLI, then sign in with gh auth login.
Commit and push the intended release commit first; the working tree must be clean.
npm run release:koreader -- koreader-v0.1.0
Use a new koreader-vMAJOR.MINOR.PATCH tag each time. The script checks that the
commit is on GitHub and the tag is unused, runs the plugin tests, builds the ZIP,
then creates the tag and publishes a GitHub release with the ZIP attached.
It targets the repository selected by gh for this checkout. Plugin releases do
not replace the server release marked Latest. This command publishes immediately;
use npm run build:koreader when you only want a local ZIP.