使用 systemfd 将终端输出管道传输到浏览器

Lobsters Hottest 工具

摘要

本文演示了一种 Bash 脚本技巧,利用 systemfd、watchexec 和 socat,在 Rust Web 服务器开发期间将终端编译日志直接管道传输到浏览器。

<p><a href="https://lobste.rs/s/pdhf7w/piping_terminal_output_browser_using">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/11 09:02

# 使用 systemfd 将终端输出流式传输到浏览器 来源: https://blog.izissise.net/posts/webdev-livecompile/ ## 契机 (https://blog.izissise.net/posts/webdev-livecompile/#an-opportunity) 我使用 `systemfd` (https://github.com/mitsuhiko/systemfd) 结合 `watchexec` (https://watchexec.github.io/) 来运行一个具有紧密开发循环的 Web 服务器项目。虽然本教程中的示例使用的是 Rust 和 Cargo,但这种技术适用于任何编译型后端语言。编译后的程序可以重用已经打开的监听 socket,因此 Web 客户端不会感到任何中断(socket 永远不会关闭)。`systemfd` 会在指定端口保持监听 socket 打开,而 `watchexec` 会在源文件发生变化时重新编译服务器并运行它。 **经典反馈循环** 在编译过程运行时,没有任何程序在处理监听 socket 上的请求!我们有三个选择: 1. 什么都不做,浏览器在整个编译过程中会显示空白页面。 2. 等到编译成功后再杀死之前的 Web 服务器版本。 3. 将编译过程流式传输到网页。 👈 我们将采用这一方案 **TL;DR:** [完整脚本](https://blog.izissise.net/posts/webdev-livecompile/#conclusion) ## Bash 脚本 (https://blog.izissise.net/posts/webdev-livecompile/#bash-script) 该脚本包含两部分:`systemfd/watchexec` 运行器以及编译/运行函数。 ## Bash 函数 (https://blog.izissise.net/posts/webdev-livecompile/#bash-function) 首先,让我们编写 `compile_and_run`,这是一个 Bash 函数,每当 `watchexec` 检测到变更时都会重新运行。我们可以从声明变量并保存一些环境上下文开始。 ```bash compile_and_run() { set -eu # 出错或未声明变量时退出 local tty_log_file fdlast socatpid tty_log_file=target/buildandrun.log # 日志文件路径 mkdir -p target # 如果日志文件目录不存在则创建 ... } ``` ## HTTP 响应 (https://blog.izissise.net/posts/webdev-livecompile/#http-response) 我们使用一个文件 (`tty_log_file`) 来构建用于显示编译日志的 HTTP 响应。利用 Bash 的 `printf`,我们可以在响应中构建一个极简的 HTML 页面,该页面使用 `<xmp>` (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/xmp) 标签,这样我们就能在不需要转义特殊字符的情况下直接放入文本(编译日志)。 ```bash compile_and_run() { ... printf '%s\r\n' \ "HTTP/1.1 200 OK" \ "Content-Type: text/html; charset=utf-8" \ "Connection: close" \ "" \ "" \ "" \ > "$tty_log_file" # 使用一个 '>' 重定向清空文件并写入我们的最小 HTTP 响应头 ... } ``` ## Socat (https://blog.izissise.net/posts/webdev-livecompile/#socat) 让我们将这个响应文件发送给任何连接到监听 socket 的客户端。我们使用 `socat`,因为它有一个 `ACCEPT-FD` 选项,可以与已经打开的监听 socket 一起工作,而这正是 `systemfd` 提供给我们的。`systemfd` 将这些文件描述符 (fd) 编号放在两个环境变量 `LISTEN_FDS_FIRST_FD` 和 `LISTEN_FDS` 中。 `socat` 使用以下参数运行: - `ACCEPT-FD:"$fd",fork`:第一个 socat 地址(systemfd 监听 socket),将为新客户端 fork 一个新进程。 - `OPEN:"$tty_log_file",rdonly,seek=0,ignoreeof`:以只读模式打开文件,seek 到开头,并保持连接打开以流式传输写入文件的新字节。 - `!!OPEN:/dev/null,wronly`:将客户端 HTTP 请求重定向到 /dev/null。 - `&`:作为后台进程运行,函数将继续执行,而 socat 在后台保持运行。 我们将 socat 的 pid 保存在一个 Bash 数组中,供下一步使用。 ```bash compile_and_run() { ... socatpid=() fdlast=$(( $LISTEN_FDS_FIRST_FD + $LISTEN_FDS - 1)) for fd in $(seq "$LISTEN_FDS_FIRST_FD" "$fdlast"); do socat ACCEPT-FD:"$fd",fork OPEN:"$tty_log_file",seek=0,ignoreeof,rdonly'!!OPEN:/dev/null,wronly' 2>/dev/null & socatpid+=("$!") done ... } ``` ## 清理 (https://blog.izissise.net/posts/webdev-livecompile/#cleanup) 我们在此声明一个清理函数并设置一个 `trap`,Bash 将在任何 `INT`、`TERM` 或 `EXIT` 事件上执行该函数,以杀死所有我们的 `socat` 后台进程。 ```bash compile_and_run() { ... kill_socat() { for pid in "${socatpid[@]}"; do pkill -P "$pid" 2>/dev/null || true # 杀死子进程(活动流) kill "$pid" 2>/dev/null || true # 杀死父进程(监听器) done } trap "kill_socat" INT TERM EXIT ... } ``` ## 编译并运行 (https://blog.izissise.net/posts/webdev-livecompile/#compile-and-run) 现在我们可以构建并运行我们的项目了。首先,将所有内容包装在这个块中:`{ ... } 2>&1 | tee -a "$tty_log_file"`。这将在终端和日志文件中同时显示所有输出。 在块内部,我们进行构建 (`cargo build`),如果成功,我们杀死 socat 进程并运行项目 (`exec cargo run`)。 ```bash compile_and_run() { ... { { cargo build || { echo "[build error $?, waiting for modification]"; sleep 999; }; } \ && { kill_socat; exec cargo run }; } 2>&1 | tee -a "$tty_log_file" } ``` ## 封装器 (https://blog.izissise.net/posts/webdev-livecompile/#wrappers) 现在,我们使用经典的 `systemfd` + `watchexec` 设置。通过 `systemfd --socket` 参数,我们可以指定监听 socket 的地址和端口。在这里,我们将声明两个 localhost socket,分别用于 IPv6 `[::1]` 和 IPv4 `127.0.0.1`。此示例编译并运行一个 Rust 项目,因此我们告诉 `watchexec` 监视 `.rs` 文件扩展名以及 `src/` 目录。然后,我们使用 `declare -f` 技巧让 `watchexec` 执行 `compile_and_run` Bash 函数。使用 Bash 子shell `$(...)`,我们将函数代码逐字复制到传递给 `watchexec` 的字符串参数中;我们只需要使用 `; compile_and_run` 来执行该函数。 ```bash echo 'Launching auto compile and livereload'; LISTENING_PORT=${LISTENING_PORT:-8000} systemfd \ --no-pid \ --socket http::[::1]:"$LISTENING_PORT" \ --socket http::127.0.0.1:"$LISTENING_PORT" \ -- \ watchexec \ --shell=bash \ --restart --clear --debounce 2s --notify \ --exts rs --watch Cargo.toml --watch build.rs --watch src/ \ -- "$(declare -f compile_and_run); compile_and_run"; ``` 如果浏览器在编译期间连接到该端口,现在它将显示编译输出,并伴随一个永无止境的 `HTTP/1.1` 流!**增强的反馈循环** 最终脚本位于页面末尾。 ## JavaScript (https://blog.izissise.net/posts/webdev-livecompile/#javascript) 前面的代码效果很好,但在实际使用时存在一些问题。当 HTTP 流关闭(socat 被杀死)时,浏览器会显示错误页面,并且没有颜色支持。我们需要一个客户端支持脚本 🌈 我们将使用 `xterm.js` (https://xtermjs.org/) 在页面上提供一个完整的终端,并配合一个自定义脚本来处理重连并将字节推送到 `xterm.js`。我们可以通过稍微修改 socat 发送的响应,将 JavaScript 代码片段注入页面: ```bash compile_and_run() { ... printf '%s\r\n' \ "HTTP/1.1 200 OK" \ "Content-Type: text/html; charset=utf-8" \ "Connection: close" \ "" \ "<!doctype html><head><meta charset='utf-8'>" \ "<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.css' />" \ "<script src='https://cdn.jsdelivr.net/npm/@xterm/xterm/lib/xterm.js'></script>" \ "<script src='https://cdn.jsdelivr.net/gh/izissise/webdevlive@refs/heads/main/livedevtty.js'></script>" \ "<body><xmp>" \ > "$tty_log_file" ... } ``` 你也可以通过下载脚本并使用以下 Bash 语法将其作为供应商脚本包含:`<script>$(<livedevtty.js)</script>`。 ## 我的 HTML 中的终端 (https://blog.izissise.net/posts/webdev-livecompile/#terminal-in-my-html) 现在我们可以编写客户端代码了。首先,让我们确保页面保持渲染状态,并且在流关闭时不显示错误消息。通过调用 `window.stop()`,我们告诉浏览器放弃其原生文档加载过程,否则当 socat 死亡时会导致错误页面。然后,我们通过 `fetch` 接管,允许我们在 JavaScript 中优雅地处理流的结束。接着,我们使用 JavaScript 的 `fetch` 打开相同的 URL (`window.location.href`),丢弃 `<body><xmp>` 标记之前的所有字节,并将其余部分流式传输到 `xterm.js` 终端。 ```javascript async function streamToTerminal(xtermInstance, url, startMarker) { try { const response = await fetch(url, { cache: 'no-store' }); if (!response.body) { throw new Error("ReadableStream not supported in this browser."); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let isSeeking = true; let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) { break; // 流正常结束 } let chunk = decoder.decode(value, { stream: true }); if (isSeeking) { buffer += chunk; const markerIndex = buffer.indexOf(startMarker); if (markerIndex !== -1) { isSeeking = false; // 提取标记之后的所有内容并写入 const contentStart = markerIndex + startMarker.length; const initialContent = buffer.substring(contentStart); if (initialContent) { xtermInstance.write(initialContent); } buffer = ""; } } else { xtermInstance.write(chunk); } } handleStreamEnd(); } catch (error) { handleStreamEnd(); } } async function activateLive() { window.stop(); // 阻止浏览器继续加载主页面 const xtermInstance = setupTerminal(); const startMarker = "<body>" + "<xmp>"; await streamToTerminal(xtermInstance, window.location.href, startMarker); } ``` ## 结论 (https://blog.izissise.net/posts/webdev-livecompile/#conclusion) 我制作了一个 [示例仓库](https://github.com/izissise/webdevlive)。该示例还展示了如何在服务器运行时流式传输日志文件,因此开发终端直接显示在应用程序中,进一步缩短了反馈循环! **演示** 如果存在类似 [listenfd](https://github.com/mitsuhiko/listenfd) 的库,这应该可以适应任何编程语言。 脚本 `dev.sh` ```bash compile_and_run() { set -eu local color tty_log_file fdlast socatpid tty_log_file=target/buildandrun.log color=never if [ -t 1 ]; then color=always; fi build() { cargo build --color "$color"; } # 更改为你的构建命令 run() { exec cargo run; } # 更改为你的运行命令 mkdir -p target printf '%s\r\n' \ "HTTP/1.1 200 OK" \ "Content-Type: text/html; charset=utf-8" \ "Connection: close" \ "" \ "<!doctype html><head><meta charset='utf-8'>" \ "<title>compiling</title>" \ "<script src='https://cdn.jsdelivr.net/gh/izissise/webdevlive@refs/heads/main/livedevtty.js'></script>" \ "<script>activateLive('log')</script>" \ "<body><xmp id='log'>" \ > "$tty_log_file" socatpid=() fdlast=$(( LISTEN_FDS_FIRST_FD + LISTEN_FDS - 1)) for fd in $(seq "$LISTEN_FDS_FIRST_FD" "$fdlast"); do socat ACCEPT-FD:"$fd",fork OPEN:"$tty_log_file",seek=0,ignoreeof,rdonly'!!OPEN:/dev/null,wronly' 2>/dev/null & socatpid+=("$!") done kill_socat() { for pid in "${socatpid[@]}"; do pkill -P "$pid" 2>/dev/null || true # 杀死子进程(活动流) kill "$pid" 2>/dev/null || true # 杀死父进程(监听器) done } trap "kill_socat" INT TERM EXIT { { build || { echo "[build error $?, waiting for modification]"; sleep 99999; }; } \ && { kill_socat; run }; } 2>&1 | tee -a "$tty_log_file" } echo 'Launching auto compile and livereload'; LISTENING_PORT=${LISTENING_PORT:-8008} systemfd \ --no-pid \ --socket http::'[::1]':"$LISTENING_PORT" \ --socket http::127.0.0.1:"$LISTENING_PORT" \ -- \ watchexec \ --shell=bash --quiet \ --restart --clear --debounce 2s --notify \ --exts rs --watch Cargo.toml --watch build.rs --watch src/ \ -- "$(declare -f compile_and_run); compile_and_run"; ```

相似文章

管道、Fork 和僵尸进程

Hacker News Top

本文来自哈佛大学的 CS 61 课程,涵盖了 Unix 中的管道、Fork 和僵尸进程概念,解释了在关闭时管道如何自动终止程序,以及如何使用管道实现对子进程的阻塞等待。

Ratty: A terminal emulator with inline 3D graphics

Lobsters Hottest

# Ratty — A GPU-rendered terminal emulator with inline 3D graphics 🐀🧀 Source: [https://ratty-term.org/](https://ratty-term.org/) © 2026 Ratty,[Orhun Parmaksız](https://github.com/orhun)