サーバーとのやり取り
ブラウザがアクションを呼び出すと、サーバーは差分で答えます。差分はシグナルの新しい値か、ページの一部の新しい HTML です。
アクション
Action<S, A> は、シグナルが S のページのためのサーバーのエンドポイントで、型 A の引数を受け取ります。const として宣言し、同じ値を両側で使います。ページは call() か call_with(args) で呼び出し、ルーターは RouterExt::action で配信します。args は、呼び出しを実行するときにブラウザで評価される式です。いくつもの値が要るハンドラはタプルを受け取り、ページは式のタプルを渡します。
ハンドラはルーターの状態と、デシリアライズ済みの引数を受け取り、Patches<S> に変換できる値を返します。引数をデシリアライズできないリクエストには、ハンドラが動く前に 400 を返します。
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use axum::Router;
use hypersig::html::{button, form, input, label, p, span};
use hypersig::{Action, Page, Patches, Route, RouterExt, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Profile {
name: String,
saving: bool,
error: String,
saved: String,
likes: i32,
}
hypersig::routes! {
fn pages(Router<Likes>);
const INDEX: Route = "/" => index;
}
const SAVE: Action<Profile, String> = Action::post("/save");
const LIKE: Action<Profile> = Action::post("/like");
#[derive(Clone, Default)]
struct Likes(Arc<AtomicI32>);
async fn index(likes: Likes, (): ()) -> Page<Profile> {
let name = Profile::name();
let saving = Profile::saving();
let count = Profile::likes();
let initial = Profile {
name: String::new(),
saving: false,
error: String::new(),
saved: String::new(),
likes: likes.0.load(Ordering::Relaxed),
};
Page::new("Profile", initial)
.child(
form()
.on_submit(
SAVE.call_with(name)
.indicator(saving)
.error(Profile::error()),
)
.child(label().text("Name ").child(input().bind(name)))
.child(
button()
.attr("type", "submit")
.attr_when("disabled", saving)
.text("Save"),
)
.child(p().bind_text(Profile::error()))
.child(p().bind_text(Profile::saved())),
)
.child(
button()
// The count goes up at once, and the request follows.
.on_click(count.set(count + 1).then(LIKE.call()))
.text("Like ")
.child(span().bind_text(count.to_text())),
)
}
async fn save(_: Likes, name: String) -> Patches<Profile> {
Profile::saved().patch(format!("Saved as {name}.")).into()
}
async fn like(likes: Likes, (): ()) -> Patches<Profile> {
let total = likes.0.fetch_add(1, Ordering::Relaxed) + 1;
Profile::likes().patch(total).into()
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new())
.hypersig_runtime()
.action(SAVE, save)
.action(LIKE, like)
.with_state(Likes::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}実行中の呼び出しと失敗した呼び出し
indicator(flag) は、リクエストの応答を待つ間 bool のシグナルを真にします。この例ではそれを使ってボタンを無効にしています。error(message) は、呼び出しが失敗したときに String のシグナルへエラーの文を入れ、呼び出しを送るたびに "" に戻します。失敗とはネットワークのエラーか、成功ではない応答のことです。戻してから入れ直すので、同じ失敗が 2 回続いてもシグナルは 2 回変わります。
楽観的更新
呼び出しの前に文を置くと、ページがすぐに変わり、そのあとにリクエストが送られます。count.set(count + 1).then(LIKE.call()) のように書きます。続いて、ハンドラの差分がサーバーに保存したカウントを設定します。サーバーがエラーで答えた場合、変更はブラウザに残ります。error で失敗を表示できますが、値を元に戻す処理はありません。
差分
Patches<S> は、ランタイムが順に適用する差分のまとまりです。sig.patch(value) はシグナルを 1 つ設定し、and は同じイベントに差分を足し、Patches::new().all_signals(&state) はすべてのシグナルを置き換えます。patch を持つのはページを起点とするシグナルだけなので、サーバーはフィールドを名前で指し、リストの位置では指しません。
use hypersig::{Patches, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Inbox {
unread: i32,
status: String,
}
// One signal, as a handler's whole answer.
let one: Patches<Inbox> = Inbox::unread().patch(0).into();
// Several signals in one event.
let both: Patches<Inbox> = Inbox::unread()
.patch(3)
.and(Inbox::status().patch("3 new messages".to_owned()))
.into();
assert!(both.to_event_stream().starts_with("event: hs-signals"));差分は 1 つずつ Server-Sent Event として送られます。axum を使わないサーバーには、to_event_stream がレスポンスの本文を返します。
リストの差分
patch はリストを丸ごと送るので、チャットのメッセージを 1 件ずつ patch で送ると、それまでのメッセージも毎回送り直すことになります。ListPatch は変わる項目だけを送ります。patch_append と patch_prepend はブラウザが持つリストの末尾と先頭に項目を加え、patch_upsert は同じキーの項目を置き換えるか末尾に加え、patch_remove は指定したキーの項目を取り除きます。キーは each_keyed と同じく項目の式で、ブラウザが項目ごとに計算します。patch と同じく、これらのメソッドはページを起点とする Vec のシグナルにだけあります。
use hypersig::{Patches, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Message {
id: i32,
body: String,
}
#[derive(Signals, Serialize, Deserialize)]
struct Room {
messages: Vec<Message>,
oldest: i32,
}
let messages = Room::messages();
let by_id = |message: hypersig::Item<Room, Message>| message.at(Message::id());
let incoming = Message { id: 42, body: "hello".to_owned() };
// One new message, as a stream's next event.
let new: Patches<Room> = messages.patch_append(&[incoming]).into();
// An older page goes above the messages on screen, which stay where they are.
let older = vec![Message { id: 30, body: "earlier".to_owned() }];
let page: Patches<Room> = Patches::new()
.list(messages.patch_prepend(&older))
.signals(Room::oldest().patch(30));
// An edit replaces the message with its id, and a deletion removes it.
let edited = Message { id: 42, body: "hello again".to_owned() };
let edit: Patches<Room> = messages
.patch_upsert(&[edited], by_id)
.and(messages.patch_remove(&[7], by_id))
.into();
assert!(new.to_event_stream().starts_with("event: hs-list"));each_keyed のリストの先頭に項目を加えると、画面に出ていた項目はその位置に残ります。ランタイムが、いちばん近いスクロールできる祖先要素を、新しい項目の高さの分だけスクロールします。Chrome のスクロールアンカリングもリストをスクロールした後なら同じことをしますが、いちばん上では働きません。古い項目を読み込むリストは、ちょうどいちばん上にあるときに読み込みます。ブラウザの中では Sig::prepend が同じように動きます。each で描いたリストは要素を位置で対応させるので、先頭の要素に新しい最初の項目が表示され、スクロールはしません。
フラグメント
Fragment は、サーバーが単独で描画し直せるページの一部です。フラグメントが要素の id を持つので、最初のページの id とあとの差分の id が食い違うことはありません。fragment(&value) はページの中にフラグメントを描画し、Patches::new().element(&value) はそれをもう一度送ります。ランタイムは送られた HTML とその id の要素を突き合わせ、違う部分だけを書き換えます。差分にはその要素が使う CSS のルールも含まれます。サーバーは送り直すフラグメントを単独で描画するので、ページの残りの部分とは比べられません。そこでブラウザが差分を当てたあとに比べ、ほかの要素がすでに持つ id や node ref の名前を差分が足したときは、コンソールに警告を書きます。
use std::time::Duration;
use axum::Router;
use hypersig::html::{El, Event, input, li, ul};
use hypersig::{Action, Fragment, Page, Patches, Route, RouterExt, Signals, fragment};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Search {
query: String,
}
const FRUITS: &[&str] = &[
"apple", "apricot", "banana", "cherry", "grape", "melon", "peach",
];
struct Results {
query: String,
}
impl Fragment for Results {
type Signals = Search;
const ID: &'static str = "results";
fn render(&self) -> El<Search> {
ul().children(
FRUITS
.iter()
.filter(|fruit| fruit.contains(self.query.as_str()))
.map(|fruit| li().text(*fruit)),
)
}
}
hypersig::routes! {
fn pages(Router<()>);
const INDEX: Route = "/" => index;
}
const SEARCH: Action<Search, String> = Action::post("/search");
async fn index((): (), (): ()) -> Page<Search> {
let query = Search::query();
Page::new(
"Search",
Search {
query: String::new(),
},
)
.child(
input()
.attr("aria-label", "Search fruit")
.bind(query)
.on_debounced(
Event::Input,
Duration::from_millis(200),
SEARCH.call_with(query),
),
)
.child(fragment(&Results {
query: String::new(),
}))
}
async fn search((): (), query: String) -> Patches<Search> {
Patches::new().element(&Results { query })
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new())
.hypersig_runtime()
.action(SEARCH, search);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}ストリーム
StreamAction を RouterExt::stream で配信すると、差分のストリームを返すハンドラが GET で動きます。レスポンスは、ストリームが終わるかブラウザがページを離れるまで開いたままです。ほかのユーザーなど別の場所で起きた変更を送るには、on_load から呼び出してください。
use std::time::Duration;
use axum::Router;
use futures::{Stream, stream};
use hypersig::html::{p, span};
use hypersig::{Page, Patches, Route, RouterExt, Signals, StreamAction};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Clock {
seconds: i32,
}
hypersig::routes! {
fn pages(Router<()>);
const INDEX: Route = "/" => index;
}
const TICKS: StreamAction<Clock> = StreamAction::new("/ticks");
async fn index((): (), (): ()) -> Page<Clock> {
Page::new("Clock", Clock { seconds: 0 }).child(
p().on_load(TICKS.call().reconnect())
.text("Connected for ")
.child(span().bind_text(Clock::seconds().to_text()))
.text(" s"),
)
}
fn ticks((): (), (): ()) -> impl Stream<Item = Patches<Clock>> {
stream::unfold(0, |seconds| async move {
tokio::time::sleep(Duration::from_secs(1)).await;
let next = seconds + 1;
Some((Clock::seconds().patch(next).into(), next))
})
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new()).hypersig_runtime().stream(TICKS, ticks);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}site/examples/ にあり、cargo test でビルドしています。重なった呼び出し
呼び出しは差分が届いた順に反映するので、前に送った遅い検索の答えが、あとの検索の答えを上書きすることがあります。Call::latest は、同じアクションへの latest の呼び出しで、まだ終わっていない前のものを取り消します。取り消した呼び出しの、まだ届いていない差分は捨てます。アクションがどのシグナルに差分を返すかはハンドラが決めるので、別々の要素から同じアクションを呼んでも、取り消しの対象は共通です。取り消されたハンドラはサーバーで .await の時点で止まることがあるので、latest は読み取りだけに付けてください。latest のない呼び出しは取り消されません。indicator は、それを使う呼び出しがすべて答えを受け取るまで真のままです。
use std::time::Duration;
use hypersig::html::{Event, input};
use hypersig::{Action, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Search {
query: String,
results: Vec<String>,
searching: bool,
}
const FIND: Action<Search, String> = Action::get("/find");
let query = Search::query();
// A newer search aborts the one still waiting, so its answer is the one shown.
let field = input().bind(query).on_debounced(
Event::Input,
Duration::from_millis(200),
FIND.call_with(query).indicator(Search::searching()).latest(),
);切れたストリーム
StreamCall::reconnect は、接続が失敗したとき、ストリームが途中で切れたとき、サーバーが 5xx を返したときに、ストリームを開き直します。上の時計がこの例です。1 秒待ち、そのあとは倍々に最大 30 秒まで待ちます。サーバーがストリームを閉じたとき、4xx の答え、要素がページから外れたときには送り直しません。送り直すたびに引数を評価し直します。最後に受け取ったメッセージの id のように、ストリームの差分が更新し続けるシグナルを引数に渡せば、ハンドラはそれより後の分だけを送れます。引数が変わらないストリームでは、初めに現在の値を送ってください。ページは見逃した変更に追いつきます。POST を送り直すと変更を繰り返しうるので、reconnect があるのは、常に GET で配信する StreamAction の呼び出しだけです。
複数のページで使う部品
アプリの枠にあるサインアウトのボタンや、フッターのニュースレターのフォームのように複数のページに置くものは、Part として書きます。部品は自分のシグナル構造体と、Part::render で組み立てる要素と、その構造体に対するアクションを持ちます。部品の型のフィールドを持つページは html::part(Shop::footer()) で部品を埋め込み、部品はそのフィールドの下にパスを書きます。部品の中の呼び出しはヘッダーでフィールドを伝えるので、部品のアクションに登録したハンドラ 1 つが、部品を埋め込んだどのページにも応答します。ハンドラが書き換えるシグナルと描き直すフラグメントは、呼び出し元のフィールドの下に入ります。部品のアクションを部品の外から呼ぶことと、リストの項目のフィールドに部品を埋め込むことは、コンパイルエラーになります。
Call::on_error はその呼び出しが失敗したときにエラーの文言を受け取って文を実行し、Call::on_success は差分を適用し終えたあとに文を実行します。どちらも呼び出しを書いた場所で動き、let_in_stmt で送信時の値に名前を付けられるので、サーバーの応答より先に項目を複数足すページでも、保存に失敗した項目だけに印を付けられます。storefront の例は、ニュースレターの部品を 2 つのページに置き、この方法でほしいものリストを保存します。