docs.ct2.ioの裏側
この記事はKLab Engineer Advent Calendar 2023の21日目の記事です。
個人的に作っているものの一部のドキュメントを自前でビルドしてホスティングしています。
タイトルのdocs.ct2.ioはそうしたドキュメント類をまとめて公開しているサイトです。
ドキュメント類の実際のホスティング元は歴史的な都合[1]で色々な場所にあり、ドキュメントを参照するのが困難でした。docs.ct2.ioは単純にはそれらをよりアクセスしやすい形でまとめるリバースプロキシのような存在です。
docs.ct2.ioの実態はCloudflare Workers(Rust)です。このWorkerは主に次の2つの機能をもっています。
- 特定のパスでアクセスされた場合に、それぞれのドキュメントの実際のホストにフォワーディングする
- インデックスページにアクセスされた場合に、ドキュメントの一覧とそれぞれのホスト先の生死状態を見れるページを返す
パスごとにフォワーディングする
これはちょっと泥臭い実装ですが、パスを前方一致で判定して残りの部分を別ホストのパスに付け替えてリクエストを流すといった形で実装しています。Router
やURLPattern
を使えばもっとスマートにできるかと思いますが、あまり詳細な仕様を把握できていないので今回は使っていません[2]。
async fn fetch_core(req: Request, _env: Env, ctx: Context) -> worker::Result<Response> {
// ...
if let Some(suffix) = sub_mounted("/path/to/subdoc", &req.path()) {
return proxy(suffix, req).await;
}
// ...
}
/// detects access to mounted_path or its sub-directory
fn sub_mounted<'s>(mounted_path: &str, request_path: &'s str) -> Option<&'s str> {
if request_path == mounted_path {
return Some("");
}
request_path.strip_prefix(&format!("{mounted_path}/"))
}
async fn proxy(path_suffix: &str, req: Request) -> worker::Result<Response> {
const REDIRECT_TO_BASE: &str = "https://xxx.example.com/";
if path_suffix.is_empty() {
// rustdocの出力構造の関係でもうひとつ深いディレクトリに飛ばす必要がある
return Response::redirect(req.url()?.join("/path/to/subdoc/crate-name")?);
}
let fetched_url = Url::parse(REDIRECT_TO_BASE)?.join(path_suffix)?;
Fetch::Url(fetched_url).send().await
}
path_suffix
が空の場合(つまりhttps://docs.ct2.io/path/to/subdoc
かhttps://docs.ct2.io/path/to/subdoc/
でアクセスされた場合)はcrate-name
を末尾に付与してリダイレクトさせるようになっています。これはRustdocの出力するHTMLが親ディレクトリのファイルを参照する場合があり、それが相対パスで表記されるのでただしくフォワーディングさせるための処理になっています。
ホスト先の生死状態を見れるインデックスページ
インデックスのページに何もないのは寂しいので、ドキュメント一覧と簡易的な生死判定が見れるページを作りました。
この生死判定はあまり難しいことはしておらず、単にドキュメントのトップページをFetchしてみて正常な応答があるかで見ています。
ただし、生死判定を待ってからHTMLを返すと初回表示までに時間がかかる恐れがあるため、先に一覧を返して生死判定が出来次第あとから差分を送るという形での実装になっています。いわゆるSSR Streamingみたいな感じですが、docs.ct2.ioではこれをフレームワークなしで行っています。
フレームワークレスSSR Streaming
具体的にやっていることは https://zenn.dev/yusukebe/articles/41becfd057c416 のcurlの動きを参考にして、同じようなHTMLデータを生成してStreamで送っているだけです。
このデータには置き換え先のHTMLをdisplay: none
なdivで囲ったものと、対象ノードの中身を置き換えるJavaScriptコードが含まれています。JavaScriptを含むテキストがブラウザに送られるとコードが実行され、新たに送ったdivの中身に書き換わることで表示が更新されます。
<div style="display: none;" id="Element1-1">
<p>Loaded!</p>
</div>
<script type="text/javascript">
// Element1(現)の子ノードをElement1-1(新)の子に置き換え
document.getElementById("Element1").replaceChildren(
...Array.from(document.getElementById("Element1-1").childNodes)
);
</script>
Rustでレスポンスストリーミング
Streamの構成にはWeb標準のTransformStream
を使用しています。
何かしら変換を行うわけではないのでfuturesのStream
トレイトを直接ReadableStream
[3]にできれば無駄がなく高効率ですが、Stream
の一種であるfuturesのmpsc::channel
はV8ランタイムのマイクロタスクキューの仕組みにうまく乗らないようでawait
後のコードが実行されなくなるためハングアップ判定になってしまいます。かといってマイクロタスクキューの仕組みに乗るStream
を自前実装するのは大変なのでTransformStream
を使うのが一番安定していて楽です。
Cloudflare WorkersではWeb標準のStream APIがだいたいそのまま使えます。そのため、Rustから使う場合もweb_sys
にあるTransformStream
がそのまま使えます。
これはTransformStream
に限らずweb_sys
にあるAPIのほとんどに言えるのですが、JavaScript側のAPIの自由度がかなり高いおかげでRust側にportされているコンストラクタ/メソッド名が相当長かったりオプション指定の方法も多様だったりで結構検索力と筋肉を要求されます。たぶんもうこれは慣れるしかないです。
let ts =
web_sys::TransformStream::new_with_transformer_and_writable_strategy_and_readable_strategy(
&JsValue::UNDEFINED.into(),
&web_sys::CountQueuingStrategy::new(&web_sys::QueuingStrategyInit::new(0.0))
.expect("Failed to init queuing strategy")
.unchecked_into(),
&web_sys::CountQueuingStrategy::new(&web_sys::QueuingStrategyInit::new(0.0))
.expect("Failed to init queuing strategy")
.unchecked_into(),
)?;
以降は通常のTransformStream
を使ったストリーミングと同じく、ts.writable()
でWriter側のStreamを取得して書き込みし、レスポンスではts.readable()
でReader側のStreamを取得してbodyに指定すればストリーミングされます。レスポンスだけ先に返したいので、実際の生死判定ロジックと差分送信処理はContext::wait_until
で囲って遅延させます。
async fn index(ctx: Context) -> worker::Result<Response> {
let ts = /* TransformStream初期化 */;
let w = ts.writable();
ctx.wait_until(async move {
let writer = w.get_writer().expect("Failed to get writer");
// 初期ページの内容をwriterに書き込み
// (別途ファイルとして作っておいてinclude_strで埋め込んだものを送信しています)
async fn query_doc1(w: web_sys::WritableStreamDefaultWriter) {
// doc1のトップページを試しにFetchして、結果を見て適切な差分HTMLをwに書き込む
}
async fn query_doc2(w: web_sys::WritableStreamDefaultWriter) {
// query_doc1と同じ
}
// 以降必要に応じて同じような非同期関数が続く
futures::join!(
query_doc1(writer.clone()),
query_doc2(writer.clone()),
// ...
);
JsFuture::from(writer.close())
.await
.expect("Failed to close stream");
});
let headers = [("Content-Type", "text/html")]
.into_iter()
.collect::<worker::Headers>();
Response::from_body(ResponseBody::Stream(ts.readable())).map(|r| r.with_headers(headers))
}
その他細かな話
Voltaを使っているとworker-buildがesbuildの呼び出しに失敗する
Windows環境でのみだと思いますが、Voltaを使用してesbuildをインストールしている場合にworker-build内部でのパス正規化処理がシンボリックリンクの先まで解決してしまう[4]ようでvolta-shim.exe
を直接呼び出すようになりエラーになってしまいます。
これを回避するために、パス正規化をしない雑パッチを当てたforkを自前で用意してそのworker-buildを使っています。
おわり
せっかくのAdvent Calendarなので今年やったことのひとつとしてdocsサイトプロキシの内部について書いてみました。こいついつも今年やったこと書いてるな
差分ストリーミングの機能実装は普段はフレームワーク経由でしかやらないので、それがこのサイトの技術的おもしろポイントのひとつだと思います。実装していて楽しかったです。
あとworker-buildの件はそのうちなんかいい感じに公式側でも正しく動くようになるといいですね......
Discussion