docs.ct2.ioの裏側

2023/12/21に公開

この記事はKLab Engineer Advent Calendar 2023の21日目の記事です。


個人的に作っているものの一部のドキュメントを自前でビルドしてホスティングしています。
タイトルのdocs.ct2.ioはそうしたドキュメント類をまとめて公開しているサイトです。

ドキュメント類の実際のホスティング元は歴史的な都合[1]で色々な場所にあり、ドキュメントを参照するのが困難でした。docs.ct2.ioは単純にはそれらをよりアクセスしやすい形でまとめるリバースプロキシのような存在です。

docs.ct2.ioの実態はCloudflare Workers(Rust)です。このWorkerは主に次の2つの機能をもっています。

  • 特定のパスでアクセスされた場合に、それぞれのドキュメントの実際のホストにフォワーディングする
  • インデックスページにアクセスされた場合に、ドキュメントの一覧とそれぞれのホスト先の生死状態を見れるページを返す

パスごとにフォワーディングする

これはちょっと泥臭い実装ですが、パスを前方一致で判定して残りの部分を別ホストのパスに付け替えてリクエストを流すといった形で実装しています。RouterURLPatternを使えばもっとスマートにできるかと思いますが、あまり詳細な仕様を把握できていないので今回は使っていません[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/subdochttps://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の中身に書き換わることで表示が更新されます。

追加で送信されるHTMLデータ例
<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されているコンストラクタ/メソッド名が相当長かったりオプション指定の方法も多様だったりで結構検索力と筋肉を要求されます。たぶんもうこれは慣れるしかないです。

TransformStream初期化
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を使っています。

https://github.com/Pctg-x8/workers-rs/tree/esbuild-ignore-static-asset-imports/worker-build

おわり

せっかくのAdvent Calendarなので今年やったことのひとつとしてdocsサイトプロキシの内部について書いてみました。こいついつも今年やったこと書いてるな

差分ストリーミングの機能実装は普段はフレームワーク経由でしかやらないので、それがこのサイトの技術的おもしろポイントのひとつだと思います。実装していて楽しかったです。
あとworker-buildの件はそのうちなんかいい感じに公式側でも正しく動くようになるといいですね......

脚注
  1. 何も考えてなかったともいう ↩︎

  2. URLPatternについてはJavaScript側のAPIになるので、一回使ってはみたけど文字列コピーが頻発してちょっとしんどい感じでした ↩︎

  3. Workersでは、ResponseのbodyにReadableStreamのインスタンスを指定することでレスポンス内容をストリーミングすることができます ↩︎

  4. std::fs::canonicalizeの仕様です ↩︎

Discussion