使用 systemfd 将终端输出管道传输到浏览器
摘要
本文演示了一种 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";
```
相似文章
Feedr v0.8.0 – 一个终端UI的RSS阅读器,现在可以在终端中阅读完整文章
Feedr v0.8.0 是一款基于终端的RSS阅读器,使用Rust编写,具有TUI界面,支持订阅管理、全文提取以及vim风格的导航。
我们如何(及为何)将生产环境的C++前端基础设施重写为Rust
NearlyFreeSpeech.NET 将其生产环境的C++前端基础设施(nfsncore)重写为Rust,该系统负责所有传入请求的路由、缓存和访问控制。迁移的动机是Rust的安全性保证、性能、生态系统优势以及老化的C++代码库的局限性。
管道、Fork 和僵尸进程
本文来自哈佛大学的 CS 61 课程,涵盖了 Unix 中的管道、Fork 和僵尸进程概念,解释了在关闭时管道如何自动终止程序,以及如何使用管道实现对子进程的阻塞等待。
Ratty: A terminal emulator with inline 3D graphics
# 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)
@tom_doerr: 将复杂的终端输出转换为带样式的HTML页面 https://github.com/nicobailon/visual-explainer…
visual-explainer 是一个智能体技能,可将复杂的终端输出(如 ASCII 艺术图和管道表格)转换为带有 Mermaid 图表、支持深色/浅色主题以及多种 AI 编码智能体框架的带样式的交互式 HTML 页面。