ブログにActivityPub連携を追加した
自作の静的サイトジェネレーターJikjiでこのブログを作ってから、 もう五年近く経つ。当時の私はTypeScriptにも最新のウェブ技術にもまだ慣れておらず、 ActivityPubを実装したこともなかった。しかし今ではTypeScriptにも 最新のウェブ技術にも比較的慣れ、ActivityPubは私にとって重要な技術になった。 しかも曲がりなりにもFedifyのメンテナーでありながら、 肝心のブログが連合していないことが、ずっと気になっていた。 そこでブログにActivityPub連携を追加することにした。
既存スタック: Jikji + PHP
このブログはもともと、自作のDeno製静的サイトジェネレーターJikjiを
ベースにしていた。厳密には静的サイトとは言い切れず、
昔のMovable Typeがそうだったように、単にHTMLを生成するだけでなく
PHPの一部も生成していたからだ。PHPは主にHTTPの
コンテンツネゴシエーションに使っていた。ブラウザのAccept-Language
ヘッダーを見て、漢字ハングル混じり文の朝鮮語、ハングル専用の韓国語、
英語、日本語のうち適切な言語を表示していた。それだけのことではあるが。
すでにPHPを使っているのだから、PHPで薄くActivityPubを実装することも
一応検討した。しかしPHPを使っているといっても、自分の手で書いたコードでは
なくJikjiが生成してくれるものだったから使っていただけで、PHPを手作業で
書きたいとは思わなかった。新しい記事が公開されたら自動でCreate(Article)
アクティビティをフォロワーに配送しなければならないが、そのためにはどのみち
メッセージキューのような仕組みが必要になる。PHPにメッセージキューまで
組み合わせるとなると、私見では、PHPに見合う規模と複雑さを超えてしまう
気がした。そして何より、Fedifyがあるのに、わざわざゼロからActivityPubを
実装したくはなかった。
そこでPHPを丸ごと取り除き、Fedifyを組み込むことに決めた。
新スタック: Astro + Netlify
まず最初の選択として、JikjiとPHPを捨て、静的なコンテンツを中心にした ウェブサイトづくりに特化したJavaScriptフレームワークAstroを採用する ことにした。すでに@fedify/astroという連携パッケージが存在していたことも、 Astroを選んだ大きな理由だった。
ただし、既存のCSSやHTMLテンプレートはできる限り再利用した。今のウェブ デザインに満足していたし、デザインの刷新まで手を出すと範囲が大きくなり すぎる気がした。パーマリンクも完全にそのまま維持した。全体として、 訪問者が何が変わったのか気付かないくらい表面を保ちながら、技術スタック だけを移行するのが目標だった。
デプロイ先はCloudflare WorkersとNetlifyの間で迷ったが、Fedifyをまだ
Netlifyで動かしたことがなかったので、これを機にFedifyのNetlifyサポートを
追加することにした。静的サイトのホスティングでNetlifyは何度も使ってきたが、
エッジ関数を併用するのは今回が初めてだった。基本的には静的なアセット群で
ありながら一部の機能だけが動的に動く、という発想は、九十年代末にウェブ
サイトを作る際、CGIスクリプトだけを/cgi-bin/ディレクトリに置いていた
やり方を思い出させた。
以前はブログ記事をMarkdownファイルとして保存してGitにコミットし、 プッシュするとGitHub Actionsが静的サイトをビルドしてからSFTPでデプロイ する、という流れだった。今ではGitHub Actionsをビルドパイプラインから 外せるようになった。Netlifyが勝手にビルドしてくれるからだ。結果として、 より単純になった。
Astroにはおおむね満足しており、移行も比較的スムーズだった。五年前に 作ってからほとんど更新していなかったJikjiより優れているのは当然のことだ。 Jikjiはもう保守する理由がなくなったので、リポジトリをアーカイブした。
AstroにFedifyを組み込む
@fedify/astroの最新化
ところがいざAstroにFedifyを組み込もうとすると、@fedify/astroが最新版の Astro 7に対応していなかった。内部で使っているAstroのAPI自体はそれほど 変わっていなかったが、パッケージに明記された対応範囲とテストはAstro 5 までしかカバーしていなかった。結局、ブログにFedifyを組み込む前に、 まず@fedify/astroを直す必要があった。
単にパッケージのバージョン範囲を広げただけではない。既存のテストは偽の Astroコンテキストを作ってミドルウェアを直接呼び出すものだったので、 ViteのSSR設定やアダプター間の互換性、ビルド後のサーバーにおけるリクエスト のルーティングといった問題は検出できなかった。そこでFedifyパッケージを 実際にパックして小さなAstroアプリケーションにインストールし、サーバーを ビルドして起動したうえでHTTPリクエストまで送る、という互換性テストを 新たに作った。
このテストは、Astro 5、6、7それぞれで、HTMLリクエストがAstroのページに
渡るか、ActivityPubとWebFingerのリクエストをFedifyが処理するか、それ以外
のパスではAstroの404 Not Foundレスポンスが維持されるか、といった点を
確認する。Astro 7についてはNode.jsだけでなくDenoとBunでもビルドを
試すようにした。
この作業はすでにアップストリームにマージされており、Fedify 2.4.0に 含まれる予定だ。
静的ページと動的エンドポイント
Astroプロジェクト全体はサーバー出力としてビルドしつつ、既存のブログページ
はこれまで通りプリレンダリングするようにした。一方、WebFingerやアクター、
インボックス、アウトボックス、フォロワーコレクション、ActivityPubオブジェクト
のパスは、リクエストを受けた時点でFedifyが動的に処理する。@fedify/astroが
提供するミドルウェアはリクエストのURLとAcceptヘッダーを見て、Fedifyが
処理すべきリクエストだけを横取りする。同じURLでも、HTMLをリクエストすれば
既存のAstroページが返り、ActivityPub表現をリクエストすればFedifyが作った
オブジェクトが返ることもある。
結果として、訪問者が目にするブログは依然として静的サイトに近い。新しく 加わった動的な部分は、ほとんどがフェディバースの他のサーバーだけがアクセス する場所にある。先ほどCGIを思い出したのも、この構成のせいだ。
PersonとArticle
ActivityPubを組み込むにあたっては、このブログで何をアクターとし、何を
オブジェクトとするかも決めなければならなかった。ブログのアクターには
Personタイプを選んだ。実際の公開作業はプログラムが自動でやってくれるが、
アクターが表しているのはブログのソフトウェアやサービスではなく、
この文章を書いている私自身だからだ。そのためハンドルは
@hongminhee@writings.hongminhee.orgで、アクターのウェブURLはこのブログを
指している。
各ブログ記事にはArticleタイプを選んだ。タイトルと本文があり、独立した
パーマリンクを持つ長文の文書だから、Noteよりも意味的にしっくりくると
考えた。幸い、Mastodonをはじめとする主要なActivityPub実装の大半も
Articleをサポートしている。人間が読む既存のパーマリンクはそのままにしつつ、
ActivityPubオブジェクトには/ap/articles/{year}/{month}/{slug}という形の
別のURIを与えた。Articleのurlは、あらためて既存のパーマリンクを指す。
ActivityPubオブジェクトのURIと、人間が読むウェブページのパーマリンクを、
はっきり分けたわけだ。
多言語対応は、もう少し悩んだところだ。言語ごとのページをそれぞれ別の
Articleオブジェクトとして表現すると、同じ記事に対する反応やシェアが
複数のオブジェクトに分散してしまう。そこで、同じパーマリンクに属する
漢字ハングル混じり文の朝鮮語︵ko-Kore︶、ハングル専用の韓国語
︵ko-Hang-KR︶、英語︵en︶、日本語︵ja︶の各バージョンを、一つの
Articleにまとめることにした。タイトルと概要、本文には、それぞれ言語タグ
の付いた値をすべて入れてある。JSON-LDでシリアライズすると、それぞれ
nameMap、summaryMap、contentMapとして表現される。言語別の値を
処理しない実装のために、name、summary、contentにはデフォルト言語
の値も併せて入れた。英語版があれば英語を、なければ漢字ハングル混じり文の
朝鮮語をデフォルトとした。言語別のHTMLページは、Articleのurlにも
hreflang属性の付いたLinkオブジェクトとして加えてある。
こうしておけば、受信側のサーバーが多言語の値を理解できる場合は利用者の 言語に合ったタイトルと本文を選べるし、そうでなくてもデフォルト値は表示 できる。もっとも、私が知る限りほぼすべてのActivityPub実装は、こうした 多言語の自然言語値をまだきちんと表示できていない。 Mastodonのイシュートラッカーにはこのためのイシューが立てられているし、 Hackers' Pubのイシュートラッカーにも似たような提案があるが、 実装される見込みはまだ立っていない。おそらくUIデザインの面でも検討が 必要になるだろう。
FedifyをNetlifyで動かす
静的ファイルだけをデプロイするのと違い、ActivityPubサーバーにはデプロイ が終わった後も残っていなければならない状態がある。まず、アクターの署名鍵 はデプロイのたびに変わってはならない。フォロワーの一覧も、次のデプロイで 消えてしまっては困る。この二つはNetlify Databaseに保存した。
インボックスで受け取ったアクティビティと、リモートサーバーに送る アクティビティは、Async Workloadsで作ったメッセージキューの中で処理する。 アクティビティの配送は相手サーバーの状態次第で遅くなったり失敗したり しうるので、HTTPリクエストを受けた関数の中ですべて終わらせようとしては いけない。キューに入れておけば、リクエストの受付と配送を切り離せるし、 失敗した処理も後で再試行できる。幸いFedifyはこうした処理をあらかじめ 抽象化してくれていて、バックエンドのアダプターも拡張できるように なっている。ただ、NetlifyのAsync Workloads向けのアダプターはまだなかった ので、FedifyがAsync Workloadsをメッセージキューとして使いながら、 Netlify Databaseに配送順序の状態を保存できるよう、 @fedify/netlifyというパッケージも作った。
新しい記事をフェディバース︵fediverse︶に知らせるのは、また別の問題
だった。静的サイトのビルドが終わったからといって、実行中のActivityPub
サーバーがどの記事に変化があったのか自動的に分かるわけではない。そこで、
本番環境へのデプロイが成功すると、前回のデプロイと今回のデプロイとで
記事一覧を比較する。新しく追加された記事にはCreate(Article)、内容や
更新日時が変わった記事にはUpdate(Article)、削除された記事には
Delete(Article)アクティビティを作ってフォロワーに送る。再試行しても
同じ変更には同じアクティビティIDを使い、より古いデプロイが後から同期
されて最新の状態を巻き戻してしまわないよう、デプロイの順序も確認する。
Netlifyにはデプロイプレビュー︵deploy preview︶とブランチデプロイ ︵branch deploy︶という機能があるが、これらの場合は連合機能を丸ごと 無効にした。プレビューのたびに同じブログを名乗るアクターが増えたり、 テスト用のデプロイが実際のフォロワーにアクティビティを送ってしまったり すると困るからだ。ローカルではインメモリのストレージとキューで開発でき、 本番環境でのみ永続的なデータベースとキューを使う。
ともあれ、そのおかげでFedifyは今やDeno DeployやCloudflare Workersに 加えて、Netlify Functionsもサポートするようになった。もちろん、Node.js、 Deno、Bunで動くのは相変わらず基本だ。
おわりに
今回の作業で、ブログにタイムラインや返信作成画面のようなソーシャル機能が
付いたわけではない。文章を書いて読むやり方も、既存のパーマリンクやデザイン
も、ほとんどそのままだ。ただ、このブログと各記事には、フェディバースで
通用する名前と住所ができた。読者は@hongminhee@writings.hongminhee.org
をフォローして新しい記事を受け取れるし、各記事のActivityPubオブジェクトの
URIを検索して元の文章にたどり着くこともできる。
Fedifyを保守しながら、他の開発者にActivityPubを実装する手段を提供して きたし、HolloやHackers' Pubなどを作りながらドッグフーディングも それなりにやってきたが、すでに運用中のウェブサイト、それも静的なページで できていたブログにFedifyを組み込むのは今回が初めてだった。おかげで Astroインテグレーションの互換性テストとNetlifyサポートを追加することに なったし、ドキュメントや単体テストを見ただけでは分からないデプロイや 運用上の問題にも直面した。Fedifyがソーシャルネットワークを新しく作る ためだけに使われるのではなく、すでに存在するウェブサイトが自分の姿を 保ったままフェディバースに参加するためにも使えることを、確認できた。