コンテンツにスキップ

シグナル

live ソースが言えることは1つです。 この領域はいま X を示している。ゲージにも、キューの深さにも、チャットのログにも それでよく、ジョブが終わった瞬間にはそれでは足りません。

違いは大きさではありません。配信はスナップショットなので、速いソースは間引かれ、 取りこぼしても損はしません。次の配信だけで十分だからです。指示はそうではない。 「いま始めた書き出しが出来上がった、見に行ってほしい」が真なのは一度きりで、 後から来る何かがそれを不要にすることはありません。

シグナルはそこを埋めます。名前と JSON のペイロードを、ソースから送り、 その名前でページが登録したコールバックへ渡す。

templates/jobs.go
func WatchJob(ctx context.Context, id string) iter.Seq2[Job, error] {
return func(yield func(Job, error) bool) {
for job := range jobs.Watch(ctx, id) {
if !yield(job, nil) {
return
}
if job.Done {
yield(Job{}, pw.NewSignal("app.finished", finished{URL: job.ResultURL}))
return
}
}
}
}
// コンポーネントの <script component> ブロックに置く。
export function setup({ el, onSignal }) {
onSignal("app.finished", (event) => {
window.popcornweb.navigate(event.url);
});
}

クライアント側はコンポーネントスクリプトに 置きます。setup は描画されたインスタンスごとに走り、スコープ付きの登録はその インスタンスが差し替えられると解放されます。部分更新や live 更新が、ひとつの ドキュメント訪問中に同じコンポーネントを複数回作り得るため、シグナルのハンドラでは 特に重要です。

シグナルはエラーが入る位置に yield されますが、エラーではありません。これは fs.SkipDir と同じ判断です。第2の値はどの層もすでに素通しする唯一の経路なので、 そこに制御用の値を置けば、引数も第2戻り値も要らず、テンプレート言語が固定した シグネチャも変わりません。

順序も無料で手に入ります。値とシグナルは1つのシーケンスから出てくるので、 2つの配信のあいだに yield したシグナルはそのあいだに届きます。DOM を読む ハンドラは、ソースが見せるつもりだった描画を見ます。emit コールバックや コンテキスト経由の側路にしていたら、その交錯順をランタイムが発明する羽目に なっていました。

失敗として扱われる前に分類される、という一点から3つが従います。

何も描画しない。 recover サブツリーも境界の内容もリビジョンも動きません。 画面の領域はそのままです。

何も終わらせない。 購読は生き、レスポンスは開いたまま、次の配信は普通に 届きます。ストリームを終わらせるのは、いままで通りソースが return することです。

間引かれない。 速い発信元は自分の yield で待たされ、出したものが捨てられる ことはありません。配信ではなくシグナルを選ぶ理由がまさにこれです。

名前は引き当てのキーです。64 バイトまで、先頭は英字、以降は英数字とドット・ アンダースコア・ハイフン。1バイトずつ比較するので、ページの登録とソースの発信は 同じ文字列であるか、一致しないかのどちらかです。

予約された接頭辞が2つあり、発信時に弾かれます。tb. はテンプレートランタイム、 pw. はこのフレームワークのもので、ライフサイクル名が届くのが そこです。ハンドラがその名前を信用できるのは、アプリケーションのコードがその 名前空間に到達する経路を持たないからです。それ以外の名前はアプリケーションで自由に使えます。 機能ごとにドット区切りの接頭辞を付けておけば、名前の衝突を避けられます。

ペイロードは生成済みエンコーダを持つ値で、構築時に一度だけ符号化されます。

type finished struct {
URL string `json:"url"`
Rows int `json:"rows"`
}

中身は誰も見ません。書いたとおりブラウザに届きます。これを明示しておくのは、 ここで2つの習慣が崩れるからです。

ペイロードに入れたものは公開されます。 投影は起きません。recover 節がエラーを 安全な4フィールドに絞れるのはその型をランタイムが定義しているからで、ペイロードは あなたが名付けた構造体です。内部の識別子やエラー文字列を入れれば、そのまま ブラウザに届きます。

共有ソースは全員に送ります。 ファンアウトはソースの中でやるアプリケーション側の 仕事なので、1つの上流から20画面に配るソースは、シグナルもその20画面すべてに 出します。共有された配信は構造上たいてい共有してよいデータですが、指示はたいてい 誰か宛てです。ユーザ固有のものを載せるなら、購読ごとのソースから出してください。

接続が開いていないあいだに出したシグナルは保持されず、再接続は切断中の出来事を 再生せず、サーバはブラウザがそれを配ったかどうかを知りません。

だから「確実に一度だけ届かなければならない指示」はここに置けません。実用的な 判定は、リロードした読者がそれでも気づけるかどうかです。ジョブのページは自分の 描画で「完了」と言うべきで、シグナルはそのリロードを省いてあげるためのものです。

上限は html.live_max_signal_bytes、既定でレスポンスあたりペイロード 256 KiB。 到達したらレコードを落とすのではなく retry でクローズします。再接続はページを 再実行してソースが現在の内容を言い直しますが、落としたレコードはただ消えるからです。

ランタイム自身も同じテーブルへ、pw. 接頭辞で配ります。ブラウザだけが観測できる 到着です。

名前 発火 運ぶもの
pw.document_committed ストリームしたドキュメントが終わった final / live_pending / failed
pw.document_truncated 終端マーカーなしでパースが終わった なし
pw.boundary_settled await 境界の内容が DOM に入った 境界 ID
pw.live_opened live レスポンスが配信を始めた 再接続かどうか
pw.live_closed 接続が終わった 理由と retry ヒント
pw.delivery_applied live の配信が着地した 境界 ID と、DOM が変わったか
pw.navigation_applied ナビゲーション delta を適用した いま表示している URL
pw.directive_received navigate / reload 指示が来た どちらか、と遷移先

いずれも、それが述べている対象が DOM に入ったに発火します。用途が「いま 届いたものを読む・飾る」だからです。pw.document_truncated だけは例外で、 不在を述べるものなので、後に回すべき対象がありません。

pw.delivery_appliedchanged を運ぶのは知っておく価値があります。サーバは 画面がすでに持っている内容と一致する配信を送りませんし、ブラウザも同一の配信では ノードに触れません。つまり到着は変化ではない。領域を光らせるハンドラはこのフラグを 読むべきで、到着回数をソースの出力とみなすカウンタは両方向に間違えます。

名前はページのテーブルだけに引き当てられます。eval でもグローバルの探索でも、 ペイロードが名指しした属性でもありません。この制約こそが、script-src 'self' のまま、unsafe-inline も nonce もなしでこの機能を成立させています。ワイヤーを 渡るのはキーとデータで、コードは最初からページにあったからです。

すると本当の問いは配送時ではなく登録時に移ります。名前の一致は検査されますが、 ハンドラが任意のペイロードに対して何をするかは検査されません。次の2つは同じ名前を 公開していて、与えている権限がまるで違います。

onSignal("app.finished", () => window.popcornweb.navigate("/exports/latest"));
onSignal("app.finished", (event) => window.popcornweb.navigate(event.url));

前者はサーバに「いつ」だけを言わせます。後者は「どこへ」を言わせるので、その URL が 他人の書ける行から来た瞬間にオープンリダイレクトになります。答えはクロージャに 閉じ込めるのが基本で、遷移先が本当に可変なら、呼び出し地点で検証してください。

fasthttp 版も併せてビルドするなら

Section titled “fasthttp 版も併せてビルドするなら”

pw.NewSignal ではなく pwruntime.NewSignal を呼びます。pw.NamedSignal も 同様です。関数としては同じもので、pw はこれらを再エクスポートしているにすぎま せん。違うのは置き場所のほうです。ソースはライブページの中で唯一トランスポートを 名指ししない部分——リクエストを取らず、レスポンスに書かず、シーケンスを返すだけ ——なので、ビルドタグで除外されないファイルに置かれます。そして pw は fasthttp ビルドに入りません。

つまり、ソースから pw を直接 import すると、その import だけでビルドに失敗します。

それ以外は何も変わりません。両方のバックエンドが同じレコードを書き、どちらも pw. プレフィックスを予約し、ページ側のスクリプトは自分がどちらと話しているのか を知りません。第二のビルドが何なのかは ビルドターゲット にあります。

状態には使わない。領域が表示するものは配信です。表示状態を運ぶシグナルは形が 間違っていて、ライブレンダリングを安全にしている再接続時の振る舞いを捨てることに なります。

クライアントがすでに知れることには使わない。配信が着地したことを知りたいなら pw.delivery_applied であって、値のたびにソースが出すシグナルではありません。

汎用のリモート呼び出しにはしない。クライアントに指示できることの集合はビルド時に 固定されていて、それはテーブルが持っているものそのものです。ペイロードの中身で 分岐するハンドラは、1つの登録で全部を公開したことになります。

クライアント側のライフサイクルと解放規則は コンポーネントスクリプトにあります。