Rust in Everything
基于 topcoat 0.10.0

htmx、Datastar 与 Alpine AJAX

主线位置:请求主线的两端。浏览器端换成了第三方库,topcoat 这一侧只是在 handler 里读几个请求头、在响应上加几个头或换一种响应体,路由、layout、OriginLayer 都照常走。和 05 浏览器运行时 是两条并列的路。

flowchart LR
  subgraph browser["浏览器"]
    hx["htmx:hx-get、hx-post"]
    ds["Datastar:data-on、@post"]
    al["Alpine AJAX:x-target"]
  end
  hx -->|"HX-Request: true"| layer["OriginLayer、layer、layout"]
  ds -->|"Datastar-Request: true"| layer
  al -->|"X-Alpine-Request: true"| layer
  layer --> h["handler:hx_request、Signals、ajax_targets"]
  h -->|"HTML 片段 + HX-* 响应头"| hx
  h -->|"text/event-stream"| ds
  h -->|"HTML 片段"| al

三个 crate 加起来 2.4k 行

cratesrc 行数(含测试)请求一侧响应一侧feature
topcoat-htmx1.0khx_request、hx_target、hx_trigger 等 8 个函数HxPushUrl、HxRetarget、HxReswap、HxResponseTrigger、HxLocation 等,都实现 IntoResponsePartshtmx
topcoat-datastar1.2kdatastar_request、Signals<T> 提取器PatchElements、PatchSignals、ExecuteScript 是 SSE 事件;DatastarSelector、DatastarMode 等是普通响应的头datastar(隐含 sse)
topcoat-alpine-ajax0.2kajax_request、ajax_targets、ajax_target没有alpine-ajax

三个都不带浏览器端脚本。上游文档的写法是在 layout 里用 asset! 引 CDN 地址,例如 asset!("https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"):bundler 打包时下载,之后从 /_topcoat/assets/ 发出去(asset! 与 bundler)。Alpine AJAX 的文档示例直接写 CDN 的 src,没有经过 asset!。

请求一侧的函数全是同一个样子,htmx 的 hx_request:

#[track_caller]
fn header<'cx>(cx: &'cx Cx, name: &HeaderName) -> Option<&'cx str> {
    request_context::<Parts>(cx)
        .headers
        .get(name)?
        .to_str()
        .ok()
}

pub fn hx_request(cx: &Cx) -> bool {
    header(cx, &header::HX_REQUEST) == Some("true")
}

从 Cx 里取请求的 Parts,读一个头,和 "true" 比。Alpine AJAX 的 ajax_request 一字不差,只是头名换成 X-Alpine-Request;ajax_targets 把 X-Alpine-Target 按空白切开。这些函数可以在 page、layout、component 里随处调用,是 03 章「函数,而不是中间件」的又一个例子。

实验

依赖:topcoat =0.10.0(full)、tokio =1.54.1、serde、futures-util。一个 layout 按请求头决定给片段还是整页(照上游文档的写法),几条路由各演示一种响应:

#[layout]
async fn root(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
  Ok(view! {
    if hx_request(cx) || ajax_request(cx) {
      (slot)
    } else {
      <!DOCTYPE html>
      <html><body><nav>"nav"</nav><main>(slot)</main></body></html>
    }
  })
}

#[route(POST "./save")]
async fn save(cx: &Cx) -> Result<(HxRetarget, HxReswap, HxResponseTrigger, ViewHandle)> {
  let body = view! { cx => <div>"Saved!"</div> }.single().await?;
  Ok((
    HxRetarget::from("#status"),
    HxReswap(SwapOption::InnerHtml),
    HxResponseTrigger::after_swap([HxEvent::with_data("show-toast", "Saved!")?]),
    body,
  ))
}

#[route(POST "./ds/inc")]
async fn ds_inc(Signals(counter): Signals<Counter>) -> Result<PatchSignals> {
  PatchSignals::json(&Counter { count: counter.count + 1 })
}
// ... GET 版本的 ds_inc、PatchElements、一个 Sse 流

curl 的结果(去掉了 date):

### GET /items
vary: Accept
vary: accept-encoding
<!DOCTYPE html><html><body><nav>nav</nav><main><ul id="items"><li>one</li></ul>...</main></body></html>

### GET /items  -H 'HX-Request: true' -H 'HX-Target: list'
vary: Accept
vary: accept-encoding
<ul id="items"><li>one</li></ul><p>hx-target=list alpine-targets=</p>

### GET /items  -H 'X-Alpine-Request: true' -H 'X-Alpine-Target: items  status'
<ul id="items"><li>one</li></ul><p>hx-target=- alpine-targets=items,status</p>

### POST /save  -H 'HX-Request: true'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Accept
hx-retarget: #status
hx-reswap: innerHTML
hx-trigger-after-swap: {"show-toast":"Saved!"}

<div>Saved!</div>

响应头就是这些类型各自 IntoResponseParts 插进去的:元组里排在 body 前面的元素依次改 Parts(请求、响应与错误)。HxResponseTrigger 的 header_value 在所有事件都没有数据时写成逗号分隔的事件名,有一个带数据就整体写成 JSON 对象。

片段和整页共用一个 URL,却没有 Vary: HX-Request

/items 对同一个 URL 给出两种内容:普通导航拿到整页,htmx 请求拿到片段。两个响应的 Vary 都只有 Accept 和 accept-encoding,没有 HX-Request。topcoat-htmx 里没有任何地方写 Vary。前面有 CDN 或反向代理缓存时,缓存分不清这两种响应,可能把片段当整页发给下一个访客;浏览器自己的 HTTP 缓存也按 URL 加 Vary 区分响应,道理相同。在 layer 里补一个就行:

#[layer]
async fn vary_hx(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
  let mut response = next.run(cx, body).await?;
  response.headers_mut().append(header::VARY, HeaderValue::from_static("HX-Request"));
  Ok(response)
}
vary: Accept
vary: HX-Request
vary: accept-encoding

Alpine AJAX 的 X-Alpine-Request 同理。

Datastar:一次补丁也是一条 SSE

### POST /ds/inc  -H 'Datastar-Request: true' -d '{"count":41}'
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
transfer-encoding: chunked

event: datastar-patch-signals
data: signals {"count":42}

### GET /ds/inc?datastar=%7B%22count%22%3A1%7D
event: datastar-patch-signals
data: signals {"count":2}

### GET /ds/inc
HTTP/1.1 400 Bad Request

bad request: failed to read the `datastar` query parameter: missing field `datastar`

### POST /ds/entry
event: datastar-patch-elements
data: selector #feed
data: mode prepend
data: elements <li>A new entry</li>
  • Signals<T> 是一个 FromRequest 提取器:GET 从查询参数 datastar 里读 JSON,其他方法读请求体。它不看 Datastar-Request 头,不带头的 POST 一样能解析;只有 Option<Signals<T>>(OptionalFromRequest)先看这个头,不是 Datastar 请求就给 None。signals 和 topcoat 自己的 signal 一样是浏览器送来的,要当用户输入校验。
  • PatchElements 直接作为返回值时,single_event_response 把它包成只有一个事件的 Sse 流。转成事件时 HTML 按行拆开,每行一个 data: elements ...;默认模式(按 id morph)不写 mode 行。
  • 选择器里不能有换行,selector() 里有 assert!,带换行直接 panic(#[track_caller] 指向调用处),不会变成一个可以注入额外 SSE 字段的事件。

持续推送就返回 Sse 流。一个每 200 毫秒发一个进度的流,用一个打印每次 read 的小客户端读:

[     0.4ms] +    161 total=161
[   202.9ms] +     65 total=226
[   405.2ms] +     66 total=292
[   607.6ms] +     72 total=364

响应头立刻发出,事件逐个到达。带上 accept-encoding: gzip, br 结果一样,没有 content-encoding:压缩的判定照搬 tower-http 的默认值,text/event-stream 不压缩,否则压缩器会攒数据,事件就不再是实时的了(tower 互操作与可信代理的压缩一节)。KeepAlive::new() 默认 15 秒发一个空注释保活。

Origin 检查照常生效

这三个库发的都是同源的 fetch / XHR,浏览器带 Sec-Fetch-Site: same-origin,OriginLayer 放行。同一个 POST /ds/inc 加上 Sec-Fetch-Site: cross-site 是 403 forbidden,和普通表单一样(Origin 检查、cookie 与 session)。

和自带运行时怎么选

topcoat 运行时(05 章)htmx / Alpine AJAXDatastar
交互写在哪$(...) 里的 Rust 子集,编译期检查HTML 属性HTML 属性 + Datastar 表达式
局部刷新shard 端点或整页重跑,返回 HTML morph任意路由返回片段,按 hx-target / x-target 替换SSE 事件补丁元素或 signal
浏览器状态signal,重跑时随请求带回基本没有,状态在 DOM 和服务端signal,@get 时放查询参数,其他方法放请求体
服务端要写的shard / procedure,各有端点普通路由,按请求头分支普通路由,返回 SSE
成熟度上游标「highly experimental」第三方库自己的版本节奏,topcoat 只做头的读写同左

两者可以混用:自带运行时的脚本只在 script() 放进 <head> 时才加载,不用它就完全不出现。选择的依据主要是团队已经熟悉什么:已有 htmx 习惯的项目,topcoat 提供的是组件、路由、流式和安全默认值,交互层照旧;想把交互逻辑也写成类型检查的 Rust,再上自带运行时。

mail!:同一套 view! 渲染邮件

mail feature 不是交互集成,放在这里顺带讲。mail! 的 html 字段接受 view! 语法,send 从应用上下文取 MailConfig、交给其中的 Transport:

pub async fn send(cx: &Cx, mail: Mail) -> Result<Receipt> {
    let config: &MailConfig = app_context(cx);
    config.transport.send(cx, mail).await
}

没注册 .mail(...) 就是 03 章那种 app_context panic。传输有三种:SmtpTransport(mail-smtp feature)、写 .eml 文件的 FileTransport、给测试用的 MemoryTransport,自己实现 Transport 就能接别的发信服务。用 FileTransport 发一封:

let m = mail! {
  from: ("Topcoat", "welcome@example.com"),
  to: "ada@example.com",
  subject: "Welcome, Ada!",
  html: {
    <h1>"Welcome, " (name) "!"</h1>
    <p>"Your account is "<strong>"ready"</strong>"."</p>
  },
}?;
let receipt = send(cx, m).await?;

写出的文件(Message-ID 里的主机名换成了占位):

From: Topcoat <welcome@example.com>
To: ada@example.com
Subject: Welcome, Ada!
Message-ID: <lzqVebl268sfPVa8zcySSaQ5oYTmORsUiduN@<主机名>>
MIME-Version: 1.0
Content-Type: multipart/alternative;
 boundary="mlOrGosUZMfGCHTPNpQ44ta8vIVC2NPHx7CJjluu"

--mlOrGosUZMfGCHTPNpQ44ta8vIVC2NPHx7CJjluu
Content-Type: text/plain; charset=utf-8

Welcome, Ada!

Your account is ready.
--mlOrGosUZMfGCHTPNpQ44ta8vIVC2NPHx7CJjluu
Content-Type: text/html; charset=utf-8

<h1>Welcome, Ada!</h1><p>Your account is <strong>ready</strong>.</p>

纯文本部分是从 HTML 推出来的(没有写 text 字段时的默认行为)。Message-ID 没有显式指定时在发送时生成(邮件组装用的是 lettre),实测域名部分是本机主机名;MailBuilder::message_id 可以显式指定,生产环境发信前值得确认一下。Receipt 只表示传输接受了这封信,不表示送达。

小结

  • htmx、Datastar、Alpine AJAX 三个 crate 都不带脚本,只读请求头、构造响应头或 SSE 事件;脚本自己在 layout 里引,可以用 asset! 打包 CDN 地址。
  • 同一 URL 按 HX-Request / X-Alpine-Request 给片段或整页时,响应不带对应的 Vary,前面有缓存要自己在 layer 里补。
  • Datastar 的 Signals<T> 在 GET 时读查询参数 datastar,其他方法读请求体;PatchElements、PatchSignals 单独返回也是一条 SSE;SSE 不被压缩,事件实时到达。
  • 三者发的请求都过 OriginLayer,跨站的照样 403;和自带运行时可以混用。
  • mail! 用 view! 写 HTML 正文,自动推出纯文本;三种传输,Transport 可自己实现。

💬 关联讨论

发起讨论
加载中...