Rust in Everything
基于 dioxus 0.7.10

自定义 404 页面返回 200

主线位置:首屏主线「等 suspense」之后、定状态码那一步。render_to 只在根错误边界里找到 ParseRouteError 时才回 404;错误被别处接住,或者根本没有错误,状态码就是 FullstackContext 里的那个,默认 200。这张错误到状态码的表见一次请求的渲染,路由那一侧见Router 运行时的「服务端:MemoryHistory、404 和状态码」。

flowchart TB
  req["GET /user/x"] --> parse{"路由能解析吗"}
  parse -->|不能| err["Router 抛 ParseRouteError"]
  err --> eb{"谁接住"}
  eb -->|根错误边界| s404["404,纯文本路由表"]
  eb -->|"用户 ErrorBoundary"| s200a["200,错误页"]
  parse -->|"catch-all 匹配"| nf["NotFound 组件"]
  nf --> s200b["200,自定义页面"]
  nf --> fix["commit_http_status 404"]
  fix --> s404b["404,自定义页面"]

本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建。同一个 crate 用 cargo feature 构建出五个变体,分别启动后用 curl 请求三个路径。

现象

路由枚举(实验应用里还有二十来个别的路由,这里省略)和 app:

#[derive(Routable, Clone, Debug, PartialEq)]
enum Route {
  // ... other routes
  #[route("/")]
  Home {},
  #[route("/user/:id")]
  User { id: u32 },
  #[cfg(feature = "catchall")]
  #[route("/:..rest")]
  NotFound { rest: Vec<String> },
}

#[cfg(feature = "catchall")]
#[component]
fn NotFound(rest: Vec<String>) -> Element {
  rsx! { p { id: "page", "not found: {rest:?}" } }
}

fn app() -> Element {
  #[cfg(feature = "eb")]
  return rsx! {
    ErrorBoundary { handle_error: |e: ErrorContext| rsx! { p { id: "page", "error: {e.error().unwrap()}" } },
      Router::<Route> {}
    }
  };
  #[cfg(not(feature = "eb"))]
  rsx! { Router::<Route> {} }
}

三个变体:什么都不加(base)、加 catch-all 路由(catchall)、把 Router 包进 ErrorBoundary(eb)。每个变体请求 /user/7、/user/x(id 不是数字)、/nope/x,打印状态行、content-type 和正文里的页面部分:

##### build: base
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 404 Not Found
Failed to parse route Route did not match:
Attempted Matches:
1) Route 'Res' ('/res') did not match:
Static segment 'res' did not match instead found 'user'
2) Route 'ResSuspend' ('/res-suspend') did not match:
Static segment 'res-suspend' did not match instead found 'user'
...
##### build: catchall
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: [&#34;user&#34;, &#34;x&#34;]<!--#--></p>
== GET /nope/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: [&#34;nope&#34;, &#34;x&#34;]<!--#--></p>
##### build: eb
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
== GET /nope/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8

base 的 /nope/x 和 /user/x 一样是 404。它的完整响应头和长度:

HTTP/1.1 404 Not Found
content-length: 1996

Failed to parse route Route did not match:
Attempted Matches:
lines in body:       40

eb 的 /user/x 正文里是错误边界渲染的页面(错误消息有多行,上面按行匹配没抓到):

HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
age: 0
transfer-encoding: chunked

<p id="page" data-node-hydration="0"><!--node-id1-->error: Failed to parse route Route did not match:
  • base:状态码对了,但响应是 40 行纯文本,没有 content-type,没有页面框架,把路由枚举里每个变体的名字和路径都列给了访问者。
  • catchall:页面是自己的,但每个 URL 都能解析,/user/x、/nope/x 全是 200。页面说「找不到」,HTTP 说「成功」;搜索引擎会把这些地址当作正常页面收录。
  • eb:ErrorBoundary 接住了 ParseRouteError,页面是错误边界画的,状态码 200,路由表进了 HTML。

出处

render_to 等完 suspense 后只看根错误边界(ssr.rs):

                // If there was an error while routing, return the error with a 404 status
                // Return a routing error if any of the errors were a routing error
                if let Some(routing_error) = error.downcast_ref::<ParseRouteError>().cloned() {
                    status_code = Some(StatusCode::NOT_FOUND);
                    out_message = Some(routing_error.to_string());
                }

有状态码时整页不渲染,正文就是 out_message,也就是 ParseRouteError 的 Display:每个候选路由为什么没匹配上。这条路只在错误冒到根错误边界时才走:

  • catch-all 路由让每个路径都能解析,RouterContext::current 不再抛错,根错误边界是空的,状态码取 FullstackContext 的当前值,默认 200。
  • 用户的 ErrorBoundary 包在 Router 外面,错误在它那里就被接住了,同样到不了根。

修法

catch-all 路由的组件里自己设 404。 FullstackContext::commit_http_status 写的就是 render_to 在没有错误时读的那个状态。status404 变体:

#[cfg(feature = "catchall")]
#[component]
fn NotFound(rest: Vec<String>) -> Element {
  #[cfg(all(feature = "status404", feature = "server"))]
  dioxus::fullstack::FullstackContext::commit_http_status(
    dioxus::fullstack::http::StatusCode::NOT_FOUND,
    None,
  );
  rsx! { p { id: "page", "not found: {rest:?}" } }
}

ErrorBoundary 里认出路由错误,也设 404。 ebfix 变体:

  #[cfg(feature = "ebfix")]
  return rsx! {
    ErrorBoundary {
      handle_error: |e: ErrorContext| {
        let err = e.error().unwrap();
        // a routing error caught here still deserves a 404
        #[cfg(feature = "server")]
        if err.downcast_ref::<dioxus::router::ParseRouteError>().is_some() {
          dioxus::fullstack::FullstackContext::commit_http_status(
            dioxus::fullstack::http::StatusCode::NOT_FOUND,
            None,
          );
        }
        rsx! { p { id: "page", "error: {err}" } }
      },
      Router::<Route> {}
    }
  };
##### build: status404
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: [&#34;user&#34;, &#34;x&#34;]<!--#--></p>
== GET /nope/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: [&#34;nope&#34;, &#34;x&#34;]<!--#--></p>
##### build: ebfix
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
== GET /nope/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8

两种都是带完整页面的 404,/user/7 不受影响。几点注意:

  • 客户端没有 FullstackContext,commit_http_status 在那里什么也不做,不加 cfg 也能编译运行(Router 运行时的实验就没加);这里的 cfg 只是省掉客户端那份代码。
  • ebfix 的页面仍把 {err} 原样显示出来,也就是那张路由表。正式的 404 页面不要显示路由错误的内容,只显示一句「找不到」。
  • 开了乱序流式时,状态码要在首块之前设好,NotFound 里不能先等异步数据再设,见乱序流式下晚设的状态码和响应头丢了。

怎么确认没中招

  • 对一个肯定不存在的路径和一个参数类型不对的路径各 curl 一次,只看状态码:
curl --noproxy '*' -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18830/no/such/page
curl --noproxy '*' -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18830/user/x

两个都应该是 404,且 content-type 是 text/html(自定义页面)。是 200,就是 catch-all 或外层 ErrorBoundary 吞掉了;是没有 content-type 的 404,就是访问者看到的是路由表。

  • 搜 #[route("/:.. 和包在 Router 外面的 ErrorBoundary,确认对应的组件里有 commit_http_status。

小结

  • 路由解析失败只在错误冒到根错误边界时变成 404,正文是列出所有路由的纯文本。
  • catch-all 路由让所有路径都能解析,外层 ErrorBoundary 把错误截在半路:两种都让 404 页面变成 200。
  • 修法:在 catch-all 组件或错误边界的 handle_error 里 FullstackContext::commit_http_status(StatusCode::NOT_FOUND, None),只在服务端调用。

💬 关联讨论

发起讨论
加载中...