signal、shard 与 procedure
主线位置:交互主线的后半段。上一页讲到浏览器能在本地重算表达式;本页讲浏览器什么时候、以什么形式把服务端叫回来,以及服务端怎么接住这些请求。
sequenceDiagram participant B as 浏览器运行时 participant O as OriginLayer participant R as RuntimeLayer participant P as page 与 layout participant S as shard 端点 participant F as procedure 端点 B->>B: signal 变化,只被表达式读过:本地重算 B->>O: 页面里 tracked 的 signal 变化:POST 页面 URL,X-Topcoat-Runtime O->>R: 同源,放行 R->>P: rewrite 成 GET,signal 值放进上下文 P-->>B: NDJSON 帧,morph 进 DOM B->>S: shard 参数或 shard 里 tracked 的 signal 变化:POST shard URL S-->>B: NDJSON 帧,只替换 shard 标记之间 B->>F: 事件处理器里 await procedure:POST JSON 数组 F-->>B: 带类型标签的 JSON 结果
准备:脚本、资产、runtime、discover
上游文档给出的最小配置:
module_router!()
.discover()
.assets(AssetBundle::load().unwrap())
.runtime()
.build()
四个调用各管一件事:
topcoat::runtime::script()放进<head>,输出<script type="module" src="/_topcoat/assets/topcoat-<哈希>.js" data-topcoat-usize-bits="64">。脚本文件是一个asset!,所以要.assets(...)加载资产包才能被服务(06 章;实验里先用topcoat asset bundle打包,再用AssetBundle::load_dir加载)。.runtime()只做两件事:self.layer(RuntimeLayer).app_context(RuntimeSetup)。.discover()注册 shard 和 procedure。模块路由与发现讲过,module_router!()只收 page、layout、layer、route,不收 shard 和 procedure;漏了.discover(),页面照常渲染,浏览器一调 shard 或 procedure 就 404。
signal
pub fn signal<T>(cx: &Cx, init: impl FnOnce() -> T) -> Signal<T>
where
T: SignalValue,
{
let id = SignalId::derive(identity(cx), Location::caller());
let value = try_request_context::<SignalValues>(cx)
.and_then(|values| values.get(id))
.and_then(T::from_value)
.unwrap_or_else(init);
let signal = Signal::new(id, value);
let declaration = signal.declaration();
hoist(move |parts| {
parts.push_comment(|comment| {
// The declaration carries untrusted application data, so it is
// escaped like any other comment body rather than pushed raw.
comment
.push_promoted_str_unescaped(&"::topcoat::signal(")
.push_string(declaration)
.push_promoted_str_unescaped(&")");
});
});
signal
}
- id:所在组件的 identity(03 章)加上调用
signal的源码位置(#[track_caller])。同一个页面每次渲染 id 都一样,浏览器才能把值送回来对上号。循环里没 key、或者在 memoize 函数里调用,identity(cx)直接 panic(view! 展开成什么里跑过)。 - 恢复:重跑请求带来的
SignalValues放在请求上下文里,按 id 取值、按类型解析;解析不了就悄悄用init,不报错。这些值全是用户输入。 - 声明:用
hoist把<!--::topcoat::signal({"t":"signal","id":...,"v":...})-->放到所在组件内容的最前面,浏览器据此建立 signal。
tracked 与 untracked
服务端读 signal 有两种:.get() / .read() 是 tracked,.get_untracked() / .read_untracked() 不是。tracked read 调 track:
fn track(&self) {
// Rust fallbacks inside expr! follow the client-reactive path too.
if crate::expr::mark_signal_read() {
return;
}
let id = self.id;
hoist_once(
HoistKey::new((TypeId::of::<SignalId>(), id)),
move |parts| {
parts.push_comment(|comment| {
comment
.push_promoted_str_unescaped(&"::topcoat::dep(\"")
.push_string_unescaped(id.to_string())
.push_promoted_str_unescaped(&"\")");
});
},
);
}
在 $(...) 里读,mark_signal_read 返回真,什么也不留;在普通 Rust 里读,就往所在的 page 或 shard 内容前面放一个 <!--::topcoat::dep("id")-->。浏览器看到这个标记,就知道「这个 signal 一变,这块内容要回服务端重渲染」。归属按最近的 shard 算,没有 shard 就是整个页面。
实验:一整套交互
页面(依赖:topcoat =0.10.0 的 full、tokio =1.54.1;layout 里放了 script();全部代码在一个 main.rs 里,/play 是一个子模块):
#[procedure("/api/double")]
async fn double(value: usize) -> Result<usize> {
println!("procedure double({value})");
Ok(value * 2)
}
#[shard("/shards/results")]
async fn results(cx: &Cx, query: String) -> Result<impl View> {
let pg = signal(cx, || 1usize);
let p = pg.get();
println!("shard results(query={query:?}, page={p})");
Ok(view! {
<p id="results">"results for '" (query) "' page " (p)</p>
<button id="next" @click=$(|_e| pg.increment())>"next"</button>
})
}
#[page]
async fn page(cx: &Cx) -> Result<impl View> {
let count = signal(cx, || 1usize);
let query = signal(cx, String::new);
let open = signal(cx, || false);
let tracked = signal(cx, || 0usize);
let t = tracked.get();
println!("page play rendered, tracked={t}");
Ok(view! {
<button id="toggle" @click=$(|_e| open.set(!open.get()))>"toggle"</button>
<p id="msg" :hidden=$(!open.get())>"hello"</p>
<button id="dbl" @click=$(async |_e| {
let doubled = double(count.get()).await;
count.set(doubled);
})>"double"</button>
<span id="count">$(count.get())</span>
<input id="q" :value=$(query.get()) @input=$(|e: Event| query.set(e.target.value))>
results(query: $(query.get()))
<button id="bump" @click=$(|_e| tracked.increment())>"bump"</button>
<span id="tracked">(t)</span>
// ... 上一页的 ret 按钮、一个记日志的 <pre>、一个驱动脚本
})
}
驱动脚本是一个单独的 JS 路由(原因见 04 章:内联脚本里的 => 会被转义),依次:点 toggle、点 double、往输入框写 rust 并派发 input 事件、点 shard 里的 next、点 bump,每步之后把 DOM 状态记下来,最后写进 <pre>。服务器上挂了一个打印每个请求的 #[layer]。无头 Chrome --dump-dom --virtual-time-budget=15000 打开 /play,<pre> 里是:
msg.hidden before=true, count=1, ret=0
msg.hidden after toggle=false
count after double=2
results after input=results for 'rust' page 1
results after next=results for 'rust' page 2
ret after click (Rust would give 1)=2
tracked after bump=1
done
服务器这边(去掉了加载脚本的两个 GET):
REQ GET /play ws=None rerun=false identity=None accept=Some("text/html,...")
page play rendered, tracked=0
shard results(query="", page=1)
REQ POST /api/double ws=None rerun=false identity=None accept=Some("*/*")
procedure double(1)
REQ POST /shards/results ws=None rerun=false identity=Some("jJw4ZqqvKgG0B8vSvgNFag") accept=Some("application/x-ndjson")
shard results(query="rust", page=1)
REQ POST /shards/results ws=None rerun=false identity=Some("jJw4ZqqvKgG0B8vSvgNFag") accept=Some("application/x-ndjson")
shard results(query="rust", page=2)
REQ GET /play ws=None rerun=false identity=None accept=Some("application/x-ndjson")
page play rendered, tracked=1
shard results(query="rust", page=2)
逐步对照:
| 操作 | 服务器收到 | 说明 |
|---|---|---|
点 toggle | 什么也没有 | open 只在表达式里读过,浏览器本地重算 hidden |
点 double | POST /api/double | procedure 走 HTTP,结果写回 count |
输入 rust | POST /shards/results,带 identity 头 | query 是 shard 的参数;页面本身没有 tracked 读 query,不重跑页面 |
点 next | 又一次 POST /shards/results | pg 在 shard 里 tracked 读过,只重跑 shard;query 还是 rust |
点 bump | GET /play,accept: application/x-ndjson | tracked 在页面里 tracked 读过,整页重跑,shard 也跟着重跑一次;pg 的值 2 随请求带回,没丢 |
最后一行的 GET 其实是浏览器发的 POST:打日志的 layer 排在 RuntimeLayer 里面,看到的是改写后的请求。把日志里的方法换成 original_method(cx),打印出来是 orig=POST。
还有一个副产品:第一次跑时日志直接 textContent += 写进 <pre>,最后只剩下页面重跑之后的两行。页面重跑时浏览器把服务端的新 HTML morph 进现有 DOM,服务端渲染的 <pre> 是空的,客户端写进去的文本就被换掉了。文档说 morph 会保留焦点、滚动位置和表单输入状态(tag 之后的 #513 又修了一处表单控件重渲染时丢掉编辑内容的问题),但脚本自己改过的普通 DOM 不在保留之列。
页面重跑:一个被改写的 POST
fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
#[cfg(not(target_family = "wasm"))]
if socket::requested(cx) {
return Box::pin(socket::accept(cx, body));
}
if rerun::requested(cx) {
return Box::pin(rerun::dispatch(cx, body));
}
next.run(cx, body)
}
rerun::requested 判断「POST 且 X-Topcoat-Runtime: true」;rerun::dispatch 把请求体解析成 {"signals": {...}},去掉 runtime 头、Content-Type、Content-Length,然后返回一个 rewrite 错误:同一路径和查询、方法换成 GET、signal 值用 .with(...) 放进新请求的上下文。router 的 rewrite 循环(从连接到分发)接住它,从头分发一遍。所以页面、layout、页面上的守卫都按一次普通的 GET 再跑一遍,这是官方建议「把你的 layer 注册在 .runtime() 之前」的原因:它们看到的是 GET。
用 curl 手工发一次(带上实验里用作「登录」的 x-user 头),count 的初值变成了请求里给的 40:
$ curl -X POST http://127.0.0.1:38203/ -H 'x-topcoat-runtime: true' -H 'x-user: bob' \
-H 'content-type: application/json' \
--data '{"signals":{"fd75393646af26f28ae06d58f5ea60c5":{"t":"usize","bits":64,"v":"40"}}}'
HTTP/1.1 200 OK
...
<!DOCTYPE html><html><head>...</head><body><!--::topcoat::signal({"t":"signal","id":"fd75393646af26f28ae06d58f5ea60c5","v":{"t":"usize","bits":64,"v":"40"}})-->...
它是一个状态修改方法的请求,最外层的 OriginLayer 照常检查:跨站发来的会在改写之前就被 403 拦下(07 章)。
shard:自己的端点
#[shard] 和组件一样在 view! 里调用,内联渲染时它的内容被 ShardScope 包在一对标记里:
<!--::topcoat::shard::start("/shards/secrets", "r8fRMoevKZ1wQlsMrgsSOw", ["(cx.hydrate({"t":"Signal","id":"ae718e55..."})).get()"])-->
<!--::topcoat::signal(...)--><!--::topcoat::dep("bfdfda03...")-->
<p id="secrets">secret results for '' page 1</p>...
<!--::topcoat::shard::end("r8fRMoevKZ1wQlsMrgsSOw")-->
起始标记里三样东西:端点 URL、这次调用的 identity(22 个字符的 URL 安全 base64)、每个参数的 JS。重渲染时浏览器把参数的当前值和 shard 里 signal 的值放进 {"args": [...], "signals": {...}}(ShardRequest),identity 放进 x-topcoat-identity 头,POST 到端点。服务端用这个 identity 作为渲染起点,所以 shard 里的 signal 算出的 id 和内联渲染时一样。端点路径默认由名字和源码位置生成,改名或挪位置就变;#[shard("/shards/secrets")] 指定一个固定路径。
同一个 tick 里的多次变化合并成一个请求,新请求会 abort 还没回来的旧请求,最新的参数胜出(浏览器端 RenderRequest 的 schedule 和 run)。
守卫拦不住 shard
实验的根页面用 03 章那套写法要求登录:
#[memoize(as_ref)]
fn current_user(cx: &Cx) -> Option<User> {
let name = headers(cx).get("x-user")?.to_str().ok()?;
Some(User(name.to_owned()))
}
fn require_auth(cx: &Cx) -> Result<&User, UnauthorizedError> {
current_user(cx).ok_or_unauthorized()
}
#[page]
async fn home(cx: &Cx) -> Result<impl View> {
let user = require_auth(cx)?;
// ...
Ok(view! { /* ... */ secrets(query: $(query.get())) })
}
shard secrets 自己没有检查。不带 x-user 请求页面:
HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
content-length: 12
unauthorized
不带 x-user、直接 POST shard 的端点,参数和 signal 值随便填:
$ curl -i -X POST http://127.0.0.1:38203/shards/secrets -H 'content-type: application/json' \
-H 'x-topcoat-identity: r8fRMoevKZ1wQlsMrgsSOw' \
--data '{"args":["admin-only"],"signals":{"bfdfda0394bb30ae009e16b2a7b9a28a":{"t":"usize","bits":64,"v":"7"}}}'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
...
<!--::topcoat::signal(...)--><!--::topcoat::dep("bfdfda0394bb30ae009e16b2a7b9a28a")--><p id="secrets">secret results for 'admin-only' page 7</p>...
服务器打印 shard secrets(query="admin-only"), x-user=None。shard 的端点是一条独立的路由,页面和 layout 一个都不跑,外面只有 OriginLayer、无路径的 layer 和路径能匹配上它的 layer。没写路径的 shard 默认挂在 /_topcoat/runtime/shards/ 下(grammar 里的 SHARD_ROUTE_PREFIX),挂在 /admin 这类业务路径上的 layer 也包不到它。文档在 shard、procedure、signal 三处都用粗体写了「在 shard 里自己鉴权、校验参数」。用 03 章「函数而不是中间件」的写法,就是在 shard 第一行也调 require_auth(cx)?。
procedure:一次 HTTP 调用
procedure 的请求体是参数元组的 JSON 数组,每个参数是带类型标签的 surrogate 值;返回值也一样:
$ curl -X POST http://127.0.0.1:38203/api/double -H 'content-type: application/json' --data '[21]'
bad request: invalid JSON value: invalid type: integer `21`, expected struct Integer at line 1 column 3 (at `[0]`)
$ curl -X POST http://127.0.0.1:38203/api/double -H 'content-type: application/json' \
--data '[{"t":"usize","bits":64,"v":"21"}]'
{"t":"usize","bits":64,"v":"42"}
参数就是用户输入。传一个 u64::MAX:
$ curl -i ... --data '[{"t":"usize","bits":64,"v":"18446744073709551615"}]'
HTTP/1.1 500 Internal Server Error
internal server error
服务器打印 attempt to multiply with overflow:debug 构建里 value * 2 溢出 panic,被 catch_unwind 变成 500(release 构建默认不检查溢出,会得到回绕后的值)。同一个请求加上 Sec-Fetch-Site: cross-site 是 403 forbidden。procedure 返回 Err 时浏览器端的表达式直接失败,拿不到错误内容;要让客户端处理失败,文档建议把错误做成数据,比如返回 Result<String, String>。
WebSocket:每个文档一条
上面的交互全走 HTTP。WebSocket 只在需要「连接后渲染」时才打开:页面或 shard 里有人调了 topcoat::runtime::connected(cx)。它在 HTTP 渲染时返回 false,同时往内容里放一个 <!--::topcoat::connect-->;浏览器看到它,在页面 URL 上开一条子协议为 topcoat-runtime 的 WebSocket,再把这个页面或 shard 重跑一遍,这次 connected(cx) 返回 true,live! 可以一直推下去。
实验页面:
#[page]
async fn page(cx: &Cx) -> Result<impl View> {
Ok(view! {
<p id="tick">(live! {
let mut n = 0u32;
loop {
let token = emit! { <span>"tick " (n) " connected=" (connected(cx))</span> }?;
if !connected(cx) || n >= 3 {
break Ok(token);
}
n += 1;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
})</p>
})
}
无头 Chrome 的 --dump-dom 在 WebSocket 建好之前就退出了,只看到 HTTP 渲染的 tick 0 connected=false。所以另写了一个 tokio-tungstenite 客户端,按浏览器端 Connection.run 的格式发一条 run 消息:
[ 6ms] handshake 101 Switching Protocols protocol=Some("topcoat-runtime")
[ 6ms] >>> {"run":1,"method":"POST","path":"/ticker","headers":{"x-topcoat-runtime":"true","content-type":"application/json"},"body":"{\"signals\":{}}"}
[ 8ms] <<< {"run":1,"frame":{"t":"snapshot","html":"<!DOCTYPE html>...<!--::topcoat::connect--><p id=\"tick\"><!--::topcoat::region::start(605264e3...)--><span>tick 0 connected=true</span><!--::topcoat::region::end(605264e3...)--></p></body></html>"}}
[ 211ms] <<< {"run":1,"frame":{"t":"swap","region":"605264e3...","html":"<!--::topcoat::connect--><span>tick 1 connected=true</span>"}}
[ 412ms] <<< {"run":1,"frame":{"t":"swap","region":"605264e3...","html":"<!--::topcoat::connect--><span>tick 2 connected=true</span>"}}
[ 616ms] <<< {"run":1,"frame":{"t":"swap","region":"605264e3...","html":"<!--::topcoat::connect--><span>tick 3 connected=true</span>"}}
服务器打印 ticker: returning at n=3, connected=true。协议的要点都在 socket.rs:
- 每条 run 消息描述一个等价的 HTTP 请求(方法、路径、头、体)。服务端把它和握手时的请求头拼成一个新请求,
router.handle_with(request, (ConnectedRender, ViewResponseDelivery::Frames))完整地分发一遍:OriginLayer、所有 layer、RuntimeLayer的改写都照走,输出是 NDJSON 帧,每帧包一层{"run":id,"frame":...}。 - run 消息能设的头只有三个:
Content-Type、x-topcoat-identity、x-topcoat-runtime(allowed_header)。cookie、Origin、Host只能来自握手,run 改不了「我是谁」。实测在 run 里塞一个x-user头,回来的是{"run":7,"frame":{"t":"error","status":400}};发一条不是 JSON 的消息,回来{"frame":{"t":"error","status":400}}。 - 握手本身是一个
GET,同样先过OriginLayer:带Origin: http://evil.example握手,客户端得到HTTP error: 403 Forbidden。 - 每个 run 是一个
tokio::spawn出去的任务;{"stop":id}或者复用同一个 id 会abort旧任务;连接断开时所有 run 一起 abort。同时进行的 run 默认最多 64 个,超过的回429,用.max_runs_per_connection(n)改。 - 浏览器端一个文档只有一个
Connection:第一个需要连接的页面或 shard 加入时打开,最后一个离开时关闭;断线后按指数退避重连,重连成功后每个成员都重新 run 一次,live!的 body 从头跑起,所以文档要求它每次都读当前状态。
和 Phoenix LiveView 比,这里服务端没有「每个连接一份常驻的视图状态」:一次 run 就是一次普通的请求分发,状态在浏览器的 signal 里,每次随请求带上来。
link:客户端导航
topcoat::runtime::link(href: ..., "About") 渲染一个普通的 <a>,带上目的地和预取模式的属性。有运行时脚本时,点击不整页刷新:浏览器用 Accept: application/x-ndjson 请求目的页,把返回的文档 morph 进当前页面。预取模式 PrefetchMode 有三种:Intent(默认,悬停片刻、获得焦点或触摸时开始加载)、Viewport(进入视口就加载)、Never。文档专门提醒:预取会在用户没点的情况下渲染目的页,页面渲染里不要有副作用,修改操作放进表单提交或 procedure。本卷没有实测 link 的浏览器行为。
小结
- 配置四件套:
script()放进<head>、.assets(...)、.runtime()、.discover();漏了.discover(),shard 和 procedure 的端点不存在。 - signal 的 id 来自组件 identity 加调用位置;重跑时的值按 id 恢复,类型不对就悄悄用初值;所有恢复的值都是用户输入。
- 服务端的 tracked read 留下
dep标记,归属最近的 shard 或整个页面;表达式里的读取只在浏览器端生效。 - 页面重跑是
POST+X-Topcoat-Runtime,被RuntimeLayer改写成GET后完整重跑;shard 和 procedure 各有自己的POST端点,不经过页面和 layout,实测不带登录头也能直接拿到 shard 的内容。 - WebSocket 只在
connected(cx)时打开,每条 run 被还原成一个 HTTP 请求完整分发,只能改三个头;握手和 run 都过OriginLayer;并发 run 默认上限 64。