個人用のセルフホスト型 RSS リーダー。Cloudflare Workers + D1 の上で動き、PC とスマートフォンから同じ購読状態を共有する。
目標は一つだけで、livedoor Reader / Fastladder の「流れるように読める」操作感を Web で再現すること。キーを押してから次の記事が出るまでの遅延がゼロであることを、機能の豊富さや見た目より優先する。
名前は、北欧神話でユグドラシルを往き来してメッセージを運ぶリスに由来する。
MVP(M0〜M9)は完了していて、購読の追加からスマホでのオフライン読みまで一通り動く。 以降は使っていて気付いたところを issue 単位で直している。実装順と、各マイルストーンで 決めたことは docs/ROADMAP.md を参照。
- キーボードだけで読み切る。
j/kで記事、s/aでフィード、Spaceで スクロールと次の記事、Shift+Sでそのフィードを全部既読。?で一覧が出る (定義はweb/src/lib/keymap.tsの 1 箇所にあり、ヘルプはそこから作られる) - 既読が端末をまたいで揃う。 PC で読んだ続きをスマホで開ける。
uを押せば その 1 件だけ未読に戻せて、それも端末をまたぐ - 購読の管理。 追加・解除、レート(読む順)、フォルダでの絞り込み、OPML の入出力。 いま見ているサイトの RSS は、ブックマークレットから登録できる
- ピン。 後で読む記事に
pを立て、zで一覧、oでまとめてタブに開く。 サーバに持つので、スマホで立てて PC で開ける - 要約しか配信していないフィードの全文取得。 本文がページのどこにあるかを Workers AI に 1 フィードにつき 1 回だけ判定させ、以降はその選択子で引く
- 先読み。 次の記事も、その画像も、送る前に手元へ来ている(仕組みは下の「設計の中核」)
- スマホと PWA。 ホーム画面に追加でき、圏外でも起動して読める。 読んだ分とピンは、繋がった時点でまとめて送られる
- 放っておいても壊れない。 5 分毎の取得、保持期間を過ぎた記事の掃除(サーバと手元の両方)、 取得が止まったフィードの警告、新着が来たことのタブアイコンでの通知
- 既読はウォーターマーク方式。記事ごとの既読フラグを持たず、フィードごとに「既読済みの最大 entry id」を 1 つ持つ。更新は常に
MAXなので単調増加し、複数端末の衝突解決がMath.maxだけで済む - 起動時に記事本文を全件先落としする。想定規模(50〜150 フィード)なら手元に置けるので、記事送りがネットワークを待たない
- 書き込みは outbox 経由。既読・ピン・レート変更はローカルに即時反映し、送信は非同期・冪等
詳細は docs/DESIGN.md、操作系は docs/UX.md、API は docs/API.md に書いてある。
Cloudflare Workers (Static Assets) / D1 / Cron Triggers / Workers AI / Vue 3 + TypeScript / Vite / Pinia / Tailwind CSS v4 / IndexedDB / Service Worker / Cloudflare Access
pnpm install
cp .dev.vars.example .dev.vars # Access の値を埋める(検証を飛ばすのは pnpm dev の --var)
cp .env.example .env # Workers AI の接続に要る(下記。デプロイ後だけ)
pnpm db:migrate # ローカル D1 にスキーマを適用
pnpm build # web/dist を作る(下記。初回だけ)
pnpm dev # vite(5173) と wrangler dev(8787) を同時起動画面は http://localhost:5173 を開く。/api は 8787 の Worker にプロキシされる(本番は同一オリジン)。
初回に pnpm build を挟むのは、wrangler.jsonc が Static Assets の置き場として web/dist を
指しているため。クローン直後は web/dist が無く、wrangler dev が
「The directory specified by the "assets.directory" field ... does not exist」で起動しない。
開発中の画面は vite が配るので、ここで作った dist は中身が古くても構わない。
一度デプロイして Access を掛けた後は、pnpm dev にも Service Token が要る。
✘ [ERROR] Failed to start the remote proxy session. Error reloading remote server:
The domain "ratatoskr.<subdomain>.workers.dev" is behind Cloudflare Access, but no
Access Service Token credentials were found and the current environment is non-interactive.
Workers AI(env.AI)はローカル実行されず必ずリモートに繋ぐが、その接続先が
デプロイ済みの workers.dev ホスト名で、そこに Access を掛けてあるため、
ローカルの wrangler が自分の Worker の入口で止められる。wrangler login は
関係ない(あれは Cloudflare API の認可。Access は HTTP の関門なので別経路)。
- Zero Trust → Access → Service Auth → Service Tokens → Create Service Token。
名前は
wrangler-devなど。Client Secret はこの一度しか表示されない cp .env.example .envして、出た値をCLOUDFLARE_ACCESS_CLIENT_IDとCLOUDFLARE_ACCESS_CLIENT_SECRETに入れる(.envは git 管理外。 Worker に渡る変数ではないので.dev.varsとは別のファイル)ratatoskrの Access アプリに、Action = Service Auth、 Include = Service Token →wrangler-devのポリシーを足す
このトークンは本番の /api/* にも到達できる(Worker 側は JWT の aud しか見ないので、
人間のログインと区別しない)。手元から出さないこと。
cloudflared access login でも通せるが、pnpm dev は concurrently 経由で
子プロセスの stdio が pipe になり、wrangler が「非対話」と判断してその経路に入らない。
Service Token なら対話・非対話を問わない。
| コマンド | 内容 |
|---|---|
pnpm dev |
開発サーバ |
pnpm build |
web をビルドして web/dist に出力 |
pnpm test |
Vitest(workerd 上で実行) |
pnpm test:e2e |
Playwright(キーバインドの確認) |
pnpm typecheck |
tsc + vue-tsc |
pnpm lint |
ESLint + Prettier |
pnpm db:console "SQL" |
ローカル D1 に SQL を投げる |
main への push と、あらゆる PR(Dependabot のものを含む)で、.github/workflows/ci.yml が上の
lint / typecheck / test / test:e2e を回す。Cloudflare の資格情報は要らない
(理由は vitest.config.ts と playwright.config.ts のコメント)。
依存の更新は Dependabot が週 1 で PR にする(.github/dependabot.yml)。minor と patch は
1 本にまとめ、TypeScript の major だけは除外している(vue-tsc が TS 7 に未対応)。
脆弱性の報告先と、何を脆弱性として扱うかは SECURITY.md を参照。
セルフホストなので、Cloudflare のアカウント 1 つに自分用の 1 台を立てる。所要は初回で 15 分ほど。
道は 2 つある。どちらで入れても、最後に Cloudflare Access を自分で掛けることになる。 入口を塞がなければ誰でも中身を読めるし、Access の AUD はアプリを作るまで決まらないので、 そこだけはボタンにも肩代わりできない。
押すと Cloudflare が次を代わりにやる。
- このリポジトリを自分の GitHub アカウントへクローンする
wrangler.jsoncを読んで D1(ratatoskr)を用意し、database_idをクローン先の設定に書き戻すpackage.jsonのbuildとdeployを拾って設定画面に埋め、そのまま実行する。pnpm deployはマイグレーションを当ててから上げるので、空のデータベースに新しいコードが向かわない.dev.vars.exampleに並んでいる名前を尋ねる
Access の値はこの時点では埋められない。 ACCESS_TEAM_DOMAIN と ACCESS_AUD は
アプリを作ってから決まる値なので、空のまま進めてよい(その間 /api/* は全て 401 を返し、
画面は開くが記事は出ない)。デプロイが終わったら下の「Access を掛ける」に進む。
入れ直す先は Worker の設定ではなく、ビルドの設定。 pnpm deploy はデプロイのたびに
この 2 つを設定ごと渡し直す(Worker を消しても 1 コマンドで戻せるようにするため)。
Worker 側にだけ入れても、次のビルドで空に戻る。
Settings → Build → Build Variables and Secrets に 2 つを入れてから、もう一度ビルドを回す。
以降は自分のアカウントのクローンが正本になる。この本家を追いかけたければ、 そちらへ upstream として繋いで取り込む。
作者はこちらで運用している。アカウント固有の値を追跡させないのがこの経路の要で、
database_id も Access の値も git 管理外の .prod.vars にだけ置く。
pnpm exec wrangler d1 create ratatoskr出力された id を、git 管理外の .prod.vars に置く。アカウント固有の値はこのファイルにだけ書く。
cat > .prod.vars <<'EOF'
D1_DATABASE_ID=<wrangler d1 create の出力の id>
# Access の値。この時点ではまだ分からないので空でよい(下の「Access を掛ける」で入れる)
ACCESS_TEAM_DOMAIN=
ACCESS_AUD=
EOFpnpm deploy は毎回ここから wrangler.deploy.json(git 管理外)を組み立てる。
wrangler.jsonc に database_id と Access の 2 つを差し込んだものだ。
- アカウント固有の値を設定ファイルに書かないのは、公開リポジトリに残さないため。
database_idはデプロイ時に必須で、データベース名だけでは解決されない - Access の 2 つを secret ではなく
varsに置いてあるのは、デプロイのたびに必ず渡すから。 secret は「渡さなければ前の版から引き継ぐ」仕組みなので、Worker がまだ無い初回デプロイでは 引き継ぎ元が無くて止まる。毎回渡すなら secret の利点(消えない)は得られず、初回が詰む欠点だけが残る - おかげで、Worker を作り直しても
.prod.varsさえあれば 1 コマンドで元に戻る
pnpm deploy # web をビルド → 設定を生成 → migrations を当てる → Worker ごと上げるマイグレーションはデプロイに含めてある。当て直すだけなら pnpm db:migrate:remote を単体で回せる。
D1 を指すコマンドは、データベース名ではなくバインディング名 DB で揃えてある
(wrangler.jsonc の d1_databases[].binding)。ボタン経由のクローンでは
データベース名が変わり得るが、バインディングはリポジトリ側が決めるので変わらない。
https://ratatoskr.<subdomain>.workers.dev が入口になる。この時点では まだ誰でも開けるが、
Access の値が空なので /api/* は全て 401 になり、記事は出ない。
Zero Trust ダッシュボード → Access → Applications → Create new application → Self-hosted。
| 項目 | 値 |
|---|---|
| Application name | ratatoskr |
| Destinations → Public hostname | ratatoskr.<subdomain>.workers.dev(デプロイで確定した入口) |
| Identity providers | Accept all available identity providers(または One-time PIN) |
| Policies | Action = Allow / Include = Emails → 自分のアドレス |
Workers & Pages の Worker → Access タブにある「Protect this Worker behind Access」は使わない。 ホスト名ではなく Worker を宛先にするアプリが作られ、こちらの手元では PIN のメールが永久に届かない 状態になった(Destinations の preview が空のまま作られる)。ホスト名を宛先にした Self-hosted なら素直に通る。
作ったアプリの Application Audience (AUD) Tag と、Zero Trust のチームドメイン
(<team>.cloudflareaccess.com。ログイン画面の URL のホスト部分でも分かる)を Worker に入れる。
-
手元から入れたなら
.prod.varsに書いて、もう一度pnpm deployACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com ACCESS_AUD=<AUD Tag> -
ボタンで入れたなら Settings → Build → Build Variables and Secrets に同じ名前の 2 つを 置いて、もう一度ビルドを回す(Worker の設定に直接入れても、次のビルドで空に戻る)
Worker は Cf-Access-Jwt-Assertion の JWT を、このチームドメインが配る公開鍵で検証し、
aud が一致するものだけ通す(src/lib/auth.ts)。
ACCESS_AUD を間違えると全ての API が 401 になるので、入れた後に画面を開いて記事が出ることを確かめる。
# Access が掛かっていれば、ログイン画面へのリダイレクトか 403 が返る。
# 200 で {"ok":true} が読めるなら、まだ誰でも中身に触れる状態
curl -sS -o /dev/null -w '%{http_code}\n' https://<ホスト名>/api/health
pnpm exec wrangler tail # cron の実行ログ(crawl / purge)を眺める/api/health はアプリ側では認証を通していない(Access の設定を壊したときに切り分けられるように)。
外から触れないのは Access が前段で止めているからで、そこが外れていれば素通しになるので、上の確認は必ず行う。
そのうえでブラウザで開き、Access のログインを通した先で記事が読めることまで見る。
購読は画面の「購読管理」から追加するか、他のリーダーから書き出した OPML を取り込む。 記事が入るのは次の定期取得(5 分毎の cron)だが、追加した分はその場で 1 回取りに行く。
wrangler.jsonc の triggers.crons に 2 つ登録してある。デプロイと同時に有効になる。
| cron | 仕事 |
|---|---|
*/5 * * * * |
フィードの取得(1 回 20 フィードまで) |
23 17 * * * |
保持期間を過ぎた既読記事の削除(02:23 JST) |
無料プランは 1 アカウントあたり cron 5 個までなので、他の Worker と合わせて足りなければ デプロイの最後で「Cron schedules」だけが失敗する(Worker 本体は上がっている)。
Worker を消すと、それに紐づく設定も一緒に消える。D1 は別のリソースなので残る。
.prod.vars さえ手元にあれば、次の 1 つで完全に戻せる。
pnpm deploy # Worker・cron・Access の値がまとめて復旧するAccess のアプリケーションは Worker とは別に残るので、作り直す必要はない
(ただし作り直した場合は AUD が変わるので .prod.vars を更新する)。
失うと取り返しがつかないのは「購読リスト・既読位置・ピン」の 3 つだけで、記事本文はフィードから入り直す。 この前提で、次の 3 段を用意する。
| 手段 | 守れる範囲 | 復旧の粒度 |
|---|---|---|
| D1 Time Travel | 直近 30 日(Workers 無料プランは 7 日)の任意の時点 | データベース全体 |
wrangler d1 export の SQL ダンプ |
取った時点 | データベース全体 |
| OPML の書き出し | 購読リストのみ | 購読の URL・名前・フォルダ |
D1 は自動で履歴を持っている。バックアップを取っていなくても、時点を指定して戻せる。
pnpm exec wrangler d1 time-travel info DB # いまのブックマーク
pnpm exec wrangler d1 time-travel info DB --timestamp=<ISO8601>
pnpm exec wrangler d1 time-travel restore DB --timestamp=<ISO8601>restore は上書きなので、戻す前に下のダンプを取っておく(戻した後に「やっぱり戻す前が良かった」が効かなくなる)。
pnpm exec wrangler d1 export DB --remote --output "backup-$(date +%Y%m%d).sql"30 日を超えて遡りたいとき(うっかり大量の購読を解除した、など)はこれしかない。月 1 回も取れば足りる。
--no-data を付ければスキーマだけ、--no-schema なら中身だけ取れる。
画面の購読管理から OPML を書き出す(GET /api/opml)。
リーダーを乗り換えるときも、まっさらな環境に入れ直すときもこれが起点になる。既読位置とピンは含まれない。
MIT License(LICENSE)。