15 Commits

Author SHA1 Message Date
xfy
47d8482ba6 docs: add config docs 2025-07-03 23:03:15 +08:00
xfy
d59b02fae2 chore: bump to 0.2.2 2025-07-03 22:06:32 +08:00
xfy
fffcee1d50 chore: update deps 2025-07-03 22:03:43 +08:00
xfy
25ff344474 feat(lua): add more const for lua 2025-07-02 01:21:52 +08:00
xfy
13c4570e20 fix: default internal error text 2025-07-02 01:16:49 +08:00
xfy
e92b191360 feat(lua): impl set body to lua 2025-07-02 01:11:48 +08:00
xfy
14196c937c feat: impl lua context 2025-07-02 00:55:52 +08:00
xfy
091f0c8eb5 feat: impl lua engine 2025-07-02 00:08:58 +08:00
xfy
f96e440264 chore: add more error detial 2025-07-01 21:38:23 +08:00
xfy
36cd153fab feat: add lua script route 2025-07-01 21:33:59 +08:00
xfy
f0da74a06e feat: add lua script execture 2025-06-29 20:24:13 +08:00
xfy
a5041197c7 feat: add 301 forward for auto index 2025-06-29 19:01:43 +08:00
xfy
515b36176d feat: add mlua 2025-06-26 22:02:19 +08:00
xfy
46c2f90f92 chore: update default config
add lua script path
2025-06-26 21:53:24 +08:00
xfy
64a85dd5a4 feat: add max body size support 2025-06-25 22:51:09 +08:00
29 changed files with 2338 additions and 1848 deletions

View File

@ -1,13 +1,20 @@
# Changelog
## 0.2.1
## 0.2.2 - 2025-07-03
Features:
- Support lua script
- Add max body size limit
## 0.2.1 - 2025-06-24
Features:
- `auto-index` support
- Stable rust version
## 0.2.0
## 0.2.0 - 2025-06-17
Features:
@ -15,7 +22,7 @@ Features:
- Refactor with axum
- SSL support
## 0.1.1
## 0.1.1 - 2024-07-02
Features:
@ -35,7 +42,7 @@ Fix:
- Custom error page
- Config tests
## 0.1.0
## 0.1.0 - 2024-05-13
Features:

208
Cargo.lock generated
View File

@ -316,7 +316,7 @@ dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"itertools 0.12.1",
"lazy_static",
"lazycell",
"log",
@ -324,10 +324,10 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"rustc-hash 1.1.0",
"shlex",
"syn",
"which",
"which 4.4.2",
]
[[package]]
@ -366,6 +366,16 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.17.0"
@ -386,7 +396,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "candy"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"axum",
@ -405,6 +415,7 @@ dependencies = [
"md5",
"mimalloc",
"mime_guess",
"mlua",
"reqwest",
"serde",
"serde_json",
@ -654,12 +665,28 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
dependencies = [
"serde",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.11"
@ -1190,6 +1217,17 @@ dependencies = [
"hashbrown 0.15.2",
]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@ -1221,6 +1259,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.14"
@ -1318,6 +1365,25 @@ version = "0.4.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
[[package]]
name = "lua-src"
version = "547.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42"
dependencies = [
"cc",
]
[[package]]
name = "luajit-src"
version = "210.5.12+a4f56a4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671"
dependencies = [
"cc",
"which 7.0.3",
]
[[package]]
name = "matchers"
version = "0.1.0"
@ -1335,9 +1401,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md5"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]]
name = "memchr"
@ -1396,6 +1462,55 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "mlua"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0"
dependencies = [
"anyhow",
"bstr",
"either",
"erased-serde",
"futures-util",
"mlua-sys",
"mlua_derive",
"num-traits",
"parking_lot",
"rustc-hash 2.1.1",
"rustversion",
"serde",
"serde-value",
]
[[package]]
name = "mlua-sys"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93"
dependencies = [
"cc",
"cfg-if",
"lua-src",
"luajit-src",
"pkg-config",
]
[[package]]
name = "mlua_derive"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "870d71c172fcf491c6b5fb4c04160619a2ee3e5a42a1402269c66bcbf1dd4deb"
dependencies = [
"itertools 0.13.0",
"once_cell",
"proc-macro-error2",
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@ -1511,6 +1626,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "overload"
version = "0.1.1"
@ -1583,6 +1707,28 @@ dependencies = [
"syn",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.93"
@ -1662,9 +1808,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.20"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"async-compression",
"base64 0.22.1",
@ -1730,6 +1876,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "0.38.44"
@ -1857,6 +2009,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
@ -2107,17 +2269,19 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.45.1"
version = "1.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
@ -2339,6 +2503,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.18.0"
@ -2549,6 +2719,18 @@ dependencies = [
"rustix 0.38.44",
]
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix 1.0.5",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -2732,6 +2914,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"

View File

@ -2,14 +2,14 @@
[package]
name = "candy"
version = "0.2.1"
version = "0.2.2"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# core
tokio = { version = "1.45.1", features = ["full"] }
tokio = { version = "1.46.0", features = ["full"] }
tokio-util = "0.7.15"
tokio-rustls = "0.26.2"
hyper = { version = "1.6.0", features = ["full"] }
@ -23,7 +23,7 @@ axum-extra = { version = "0.10.1", features = ["typed-header"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.6", features = ["full"] }
reqwest = { version = "0.12.20", features = [
reqwest = { version = "0.12.22", features = [
# "rustls-tls",
"native-tls-vendored",
"zstd",
@ -32,6 +32,16 @@ reqwest = { version = "0.12.20", features = [
'deflate',
"stream",
] }
# lua
mlua = { version = "0.10.5", features = [
"lua54",
"vendored",
"send",
"async",
"macros",
"serialize",
"anyhow",
] }
# tools
thiserror = "2.0.12"
anyhow = "1.0.98"
@ -39,7 +49,7 @@ serde = { version = "1.0.219", features = ["derive"] }
toml = "0.8.23"
bytes = "1.10.1"
const_format = "0.2.34"
md5 = "0.7.0"
md5 = "0.8.0"
dashmap = { version = "6.1.0", features = ["serde"] }
# logging
tracing = "0.1.41"

View File

@ -1,11 +1,26 @@
# Candy
<img src="./assets/candy-transparent.png" width="200px">
A tiny web server built with rust.
[![dependency status](https://deps.rs/repo/github/DefectingCat/candy/status.svg)](https://deps.rs/repo/github/DefectingCat/candy)
![](https://git.rua.plus/xfy/candy/badges/main/pipeline.svg)
![](https://git.rua.plus/xfy/candy/-/badges/release.svg)
## Features
- Simple and easy to use
- Single binary
- Supports SSL
- HTTP 2 support
- Supports lua script
- List directory
## TODO
[TODO.md](./TODO.md)
## Changelog
[CHANGELOG.md](./CHANGELOG.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

BIN
assets/candy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -1,7 +1,7 @@
# Virtual host
[[host]]
ip = "0.0.0.0"
port = 4000
port = 80
# Connection timeout
timeout = 15
# SSL certificate
@ -11,11 +11,9 @@ timeout = 15
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
# Add custom headers to response
[host.headers]
X-Powered-By = "candy"
# Routes for virtual host
[[host.route]]
# Route location
@ -23,12 +21,11 @@ location = "/"
# Static file root
# or proxy_pass
# or redirect
root = "./html"
root = "html"
# Only use for root field
index = ["index.html"]
# List directory
auto_index = true
# Custom 500 page
[host.route.error_page]
status = 500
@ -38,8 +35,22 @@ page = "500.html"
status = 404
page = "404.html"
# Reverse proxy
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/proxy/"
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
# Lua script
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"

View File

@ -6,4 +6,78 @@ title: 配置文件
## 配置文件
Candy 遵循配置文件进行配置。
Candy 遵循配置文件进行配置。配置文件的格式为 TOML。
### 虚拟主机
顶层配置为虚拟主机 `host`,可以配置多个虚拟主机。
```toml
[[host]]
ip = "0.0.0.0"
port = 80
# Connection timeout
timeout = 15
# 只用当 ssl = true 时,才会读取证书和密钥,并开启 SSL 支持
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
```
#### 自定义 HTTP 相应头
每个虚拟主机都可以配置自定义相应头
TODO
### 路由
每个虚拟主机下都可以配置多个路由。配置字段为 `route`
每个路由支持三种配置:
- 静态文件托管
- 反向代理
- Lua 脚本
#### 静态文件托管
```toml
[[host.route]]
# 路由地址
location = "/"
# 静态文件根目录
root = "html"
# 当使用静态文件根目录时,使用下面的字段作为主页
index = ["index.html"]
# 列出目录
auto_index = true
```
#### 反向代理
```toml
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
```
#### Lua 脚本
```toml
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"
```

View File

@ -23,3 +23,11 @@ Options:
-h, --help Print help
-V, --version Print version
```
只需要一个可执行文件和一个配置文件,就可以快速部署一个 HTTP 服务器。
```bash
./target/release/candy -c config.toml
```
`-c` 可以省略,当省略时,默认使用当前目录下的 `config.toml` 文件。

View File

@ -5,9 +5,10 @@ import { themes as prismThemes } from 'prism-react-renderer';
const config: Config = {
title: 'Candy',
tagline: 'Tiny http web server',
favicon: 'img/favicon.ico',
favicon: 'img/candy-transparent.png',
future: {
v4: true,
experimental_faster: true,
},
@ -66,8 +67,8 @@ const config: Config = {
navbar: {
title: 'RUA',
logo: {
alt: 'My Site Logo',
src: 'img/logo.svg',
alt: 'Candy Logo',
src: 'img/candy-transparent.png',
},
items: [
{

View File

@ -45,7 +45,7 @@
},
"theme.colorToggle.ariaLabel": {
"message": "Switch between dark and light mode (currently {mode})",
"description": "The ARIA label for the navbar color mode toggle"
"description": "The ARIA label for the color mode toggle"
},
"theme.colorToggle.ariaLabel.mode.dark": {
"message": "dark mode",
@ -309,5 +309,17 @@
"theme.tags.tagsPageTitle": {
"message": "Tags",
"description": "The title of the tag list page"
},
"theme.colorToggle.ariaLabel.mode.system": {
"message": "system mode",
"description": "The name for the system color mode"
},
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": {
"message": "Expand the dropdown",
"description": "The ARIA label of the button to expand the mobile dropdown navbar item"
},
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": {
"message": "Collapse the dropdown",
"description": "The ARIA label of the button to collapse the mobile dropdown navbar item"
}
}

View File

@ -7,3 +7,79 @@ title: Config File
## Config File
Candy follows the config file to configure.
### Virtual Host
The top level configuration is the virtual host `host`, and can configure multiple virtual hosts.
```toml
[[host]]
ip = "0.0.0.0"
port = 80
# Connection timeout
timeout = 15
# Only read certificate and key when ssl = true, and enable SSL support
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
```
#### Custom HTTP Response Header
Each virtual host can configure custom response header.
TODO
### Route
Each virtual host can configure multiple routes. The configuration field is `route`.
Each route supports three configurations:
- Static file hosting
- Reverse proxy
- Lua script
#### Static File Hosting
```toml
[[host.route]]
# Route location
location = "/"
# Static file root
# or proxy_pass
# or redirect
root = "html"
# Only use for root field
index = ["index.html"]
# List directory
auto_index = true
```
#### Reverse Proxy
```toml
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
```
#### Lua Script
```toml
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"
```

View File

@ -23,3 +23,11 @@ Options:
-h, --help Print help
-V, --version Print version
```
Only one config file is supported, the default config file is `./config.toml`.
```bash
./target/release/candy -c config.toml
```
`-c` can be omitted, and when omitted, the default config file is `./config.toml` in the current directory.

View File

@ -45,7 +45,7 @@
},
"theme.colorToggle.ariaLabel": {
"message": "切换浅色/暗黑模式(当前为{mode}",
"description": "The ARIA label for the navbar color mode toggle"
"description": "The ARIA label for the color mode toggle"
},
"theme.colorToggle.ariaLabel.mode.dark": {
"message": "暗黑模式",
@ -309,5 +309,17 @@
"theme.tags.tagsPageTitle": {
"message": "标签",
"description": "The title of the tag list page"
},
"theme.colorToggle.ariaLabel.mode.system": {
"message": "system mode",
"description": "The name for the system color mode"
},
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": {
"message": "Expand the dropdown",
"description": "The ARIA label of the button to expand the mobile dropdown navbar item"
},
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": {
"message": "Collapse the dropdown",
"description": "The ARIA label of the button to collapse the mobile dropdown navbar item"
}
}

View File

@ -6,4 +6,78 @@ title: 配置文件
## 配置文件
Candy 遵循配置文件进行配置。
Candy 遵循配置文件进行配置。配置文件的格式为 TOML。
### 虚拟主机
顶层配置为虚拟主机 `host`,可以配置多个虚拟主机。
```toml
[[host]]
ip = "0.0.0.0"
port = 80
# Connection timeout
timeout = 15
# 只用当 ssl = true 时,才会读取证书和密钥,并开启 SSL 支持
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
```
#### 自定义 HTTP 相应头
每个虚拟主机都可以配置自定义相应头
TODO
### 路由
每个虚拟主机下都可以配置多个路由。配置字段为 `route`
每个路由支持三种配置:
- 静态文件托管
- 反向代理
- Lua 脚本
#### 静态文件托管
```toml
[[host.route]]
# 路由地址
location = "/"
# 静态文件根目录
root = "html"
# 当使用静态文件根目录时,使用下面的字段作为主页
index = ["index.html"]
# 列出目录
auto_index = true
```
#### 反向代理
```toml
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
```
#### Lua 脚本
```toml
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"
```

View File

@ -23,3 +23,11 @@ Options:
-h, --help Print help
-V, --version Print version
```
只需要一个可执行文件和一个配置文件,就可以快速部署一个 HTTP 服务器。
```bash
./target/release/candy -c config.toml
```
`-c` 可以省略,当省略时,默认使用当前目录下的 `config.toml` 文件。

View File

@ -19,22 +19,22 @@
"tsc": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.6.3",
"@docusaurus/faster": "^3.6.3",
"@docusaurus/preset-classic": "3.6.3",
"@docusaurus/core": "3.8.1",
"@docusaurus/faster": "^3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@mdx-js/react": "^3.1.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"prism-react-renderer": "^2.4.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.6.3",
"@docusaurus/tsconfig": "3.6.3",
"@docusaurus/types": "3.6.3",
"prettier": "^3.4.2",
"@docusaurus/module-type-aliases": "3.8.1",
"@docusaurus/tsconfig": "3.8.1",
"@docusaurus/types": "3.8.1",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.1.0",
"typescript": "~5.7.2"
"typescript": "~5.8.3"
},
"browserslist": {
"production": [
@ -51,5 +51,5 @@
"engines": {
"node": ">=18.0"
},
"packageManager": "pnpm@9.15.2"
"packageManager": "pnpm@10.12.4"
}

3091
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -14,30 +14,22 @@ const FeatureList: FeatureItem[] = [
Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
description: (
<>
Docusaurus was designed from the ground up to be easily installed and
used to get your website up and running quickly.
Single executable binary, with a TOML config file, quick to deploy a
http server.
</>
),
},
{
title: 'Focus on What Matters',
title: 'Performance',
Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
description: (
<>
Docusaurus lets you focus on your docs, and we&apos;ll do the chores. Go
ahead and move your docs into the <code>docs</code> directory.
</>
<>Multiple threads, asynchronous I/O, and multi-platform support.</>
),
},
{
title: 'Powered by React',
title: 'Powered by Rust',
Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
description: (
<>
Extend or customize your website layout by reusing React. Docusaurus can
be extended while reusing the same header and footer.
</>
),
description: <>Built with Rust, axum and tokio.</>,
},
];

View File

@ -21,7 +21,7 @@ function HomepageHeader() {
className="button button--secondary button--lg"
to="/docs/intro"
>
Docusaurus Tutorial - 5min
Quick start - 5min
</Link>
</div>
</div>
@ -32,10 +32,7 @@ function HomepageHeader() {
export default function Home(): JSX.Element {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<Layout title={`${siteConfig.title}`} description="Candy, http web server">
<HomepageHeader />
<main>
<HomepageFeatures />

BIN
docs/static/img/candy-transparent.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

BIN
docs/static/img/candy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -40,6 +40,11 @@ pub struct SettingRoute {
/// Timeout for connect to upstream
#[serde(default = "upstream_timeout_default")]
pub proxy_timeout: u16,
/// Request max body size (bytes)
pub max_body_size: Option<u64>,
/// Lua script
pub lua_script: Option<String>,
}
/// Host routes

View File

@ -5,7 +5,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use const_format::{concatcp, formatcp};
use const_format::formatcp;
use serde_repr::*;
use tracing::error;
@ -36,7 +36,7 @@ pub enum ErrorCode {
}
/// Normal error message
const SERVER_ERROR_STR: &str = concatcp!(
const SERVER_ERROR_STR: &str = formatcp!(
r#"Internal Server Error
{NAME} v{VERSION}
Powered by RUA

149
src/http/lua.rs Normal file
View File

@ -0,0 +1,149 @@
use std::str::FromStr;
use anyhow::{Context, anyhow};
use axum::{
body::Body,
extract::{Path, Request},
response::{IntoResponse, Response},
};
use axum_extra::extract::Host;
use http::{HeaderMap, HeaderName, HeaderValue, Uri};
use mlua::{UserData, UserDataMethods, UserDataRef};
use tokio::fs::{self};
use tracing::error;
use crate::{
http::{HOSTS, LUA_ENGINE, error::RouteError, serve::resolve_parent_path},
utils::parse_port_from_host,
};
use super::error::RouteResult;
/// 为 Lua 脚本提供 HTTP 请求上下文
#[derive(Clone, Debug)]
struct CandyRequest {
#[allow(dead_code)]
method: String,
/// Uri 在路由中被添加到上下文中
uri: Uri,
}
/// 为 Lua 脚本提供 HTTP 响应上下文
#[derive(Clone, Debug)]
struct CandyResponse {
status: u16,
headers: HeaderMap,
body: String,
}
// HTTP 请求上下文,可在 Lua 中使用
#[derive(Clone, Debug)]
struct RequestContext {
req: CandyRequest,
res: CandyResponse,
}
impl UserData for RequestContext {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
// 获取请求路径
methods.add_method("get_path", |_, this, ()| {
Ok(this.req.uri.path().to_string())
});
// 获取请求方法
methods.add_method("get_method", |_, this, ()| Ok(this.req.method.to_string()));
// 设置响应状态码
methods.add_method_mut("set_status", |_, this, status: u16| {
this.res.status = status;
Ok(())
});
// 设置响应内容
methods.add_method_mut("set_body", |_, this, body: String| {
this.res.body = format!("{}{}", this.res.body, body);
Ok(())
});
// 设置响应头
methods.add_method_mut("set_header", |_, this, (key, value): (String, String)| {
this.res.headers.insert(
HeaderName::from_str(&key).map_err(|err| anyhow!("header name error: {err}"))?,
HeaderValue::from_str(&value)
.map_err(|err| anyhow!("header value error: {err}"))?,
);
Ok(())
});
}
}
pub async fn lua(
req_uri: Uri,
path: Option<Path<String>>,
Host(host): Host,
req: Request<Body>,
) -> RouteResult<impl IntoResponse> {
let scheme = req.uri().scheme_str().unwrap_or("http");
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
tracing::debug!("Route map entries: {:?}", route_map);
let parent_path = resolve_parent_path(&req_uri, path.as_ref());
let route_config = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
let lua_script = route_config
.lua_script
.as_ref()
.ok_or(RouteError::InternalError())
.with_context(|| "lua script not found")?;
let method = req.method().to_string();
let lua = &LUA_ENGINE.lua;
let script = fs::read_to_string(lua_script)
.await
.with_context(|| format!("Failed to read lua script file: {lua_script}",))?;
lua.globals()
.set(
"ctx",
RequestContext {
req: CandyRequest {
method,
uri: req_uri,
},
res: CandyResponse {
status: 200,
headers: HeaderMap::new(),
body: "".to_string(),
},
},
)
.map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
lua.load(script).exec_async().await.map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
// 获取修改后的上下文并返回响应
let ctx: UserDataRef<RequestContext> = lua.globals().get("ctx").map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
let res = ctx.res.clone();
let mut response = Response::builder();
let body = Body::from(res.body);
response = response.status(res.status);
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with lua")?;
Ok(response)
}

View File

@ -1,15 +1,21 @@
use std::{net::SocketAddr, sync::LazyLock, time::Duration};
use std::{
net::SocketAddr,
sync::{Arc, LazyLock},
time::Duration,
};
use anyhow::anyhow;
use axum::{Router, middleware, routing::get};
use axum::{Router, extract::DefaultBodyLimit, middleware, routing::get};
use axum_server::{Handle, tls_rustls::RustlsConfig};
use dashmap::DashMap;
use mlua::Lua;
use tower::ServiceBuilder;
use tower_http::{compression::CompressionLayer, timeout::TimeoutLayer};
use tracing::{debug, info, warn};
use crate::{
config::SettingHost,
consts::{ARCH, COMMIT, COMPILER, NAME, OS, VERSION},
middlewares::{add_headers, add_version, logging_route},
utils::graceful_shutdown,
};
@ -19,6 +25,8 @@ pub mod error;
pub mod serve;
// handle reverse proxy
pub mod reverse_proxy;
// handle lua script
pub mod lua;
/// Host configuration
/// use virtual host port as key
@ -33,6 +41,80 @@ pub mod reverse_proxy;
/// }
pub static HOSTS: LazyLock<DashMap<u16, SettingHost>> = LazyLock::new(DashMap::new);
pub struct LuaEngine {
pub lua: Lua,
/// Lua 共享字典
#[allow(dead_code)]
pub shared_table: Arc<DashMap<String, String>>,
}
impl LuaEngine {
pub fn new() -> Self {
let lua = Lua::new();
let shared_table: DashMap<String, String> = DashMap::new();
let shared_table = Arc::new(shared_table);
let module = lua.create_table().expect("create table failed");
let shared_api = lua.create_table().expect("create shared table failed");
// 创建共享字典到 lua 中
let shared_table_get = shared_table.clone();
shared_api
.set(
"set",
lua.create_function(move |_, (key, value): (String, String)| {
shared_table_get.insert(key, value.clone());
Ok(())
})
.expect("create set function failed"),
)
.expect("set failed");
let shared_table_get = shared_table.clone();
shared_api
.set(
"get",
lua.create_function(move |_, key: String| {
let t = shared_table_get.get(&key).ok_or(anyhow!("key not found"))?;
Ok(t.clone())
})
.expect("create get function failed"),
)
.expect("get failed");
module
.set("shared", shared_api)
.expect("set shared_api failed");
// 日志函数
module
.set(
"log",
lua.create_function(move |_, msg: String| {
tracing::info!("Lua: {}", msg);
Ok(())
})
.expect("create log function failed"),
)
.expect("set log failed");
module.set("version", VERSION).expect("set version failed");
module.set("name", NAME).expect("set name failed");
module.set("os", OS).expect("set os failed");
module.set("arch", ARCH).expect("set arch failed");
module
.set("compiler", COMPILER)
.expect("set compiler failed");
module.set("commit", COMMIT).expect("set commit failed");
// 全局变量 candy
lua.globals()
.set("candy", module)
.expect("set candy table to lua engine failed");
Self { lua, shared_table }
}
}
/// lua 脚本执行器
pub static LUA_ENGINE: LazyLock<LuaEngine> = LazyLock::new(LuaEngine::new);
pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
let mut router = Router::new();
let host_to_save = host.clone();
@ -40,72 +122,95 @@ pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
// convert to axum routes
// register routes
for host_route in &host.route {
// reverse proxy
if host_route.proxy_pass.is_some() {
router = router.route(host_route.location.as_ref(), get(reverse_proxy::serve));
// register wildcard path /doc/*
// lua script
if host_route.lua_script.is_some() {
// papare lua script
router = router.route(host_route.location.as_ref(), get(lua::lua));
let route_path = format!("{}{{*path}}", host_route.location);
router = router.route(route_path.as_ref(), get(reverse_proxy::serve));
router = router.route(route_path.as_ref(), get(lua::lua));
// save route path to map
{
host_to_save
.route_map
.insert(host_route.location.clone(), host_route.clone());
}
} else {
// static file
if host_route.root.is_none() {
warn!("root field not found for route: {:?}", host_route.location);
continue;
continue;
}
// reverse proxy
if host_route.proxy_pass.is_some() {
router = router.route(host_route.location.as_ref(), get(reverse_proxy::serve));
// register wildcard path /doc/*
let route_path = format!("{}{{*path}}", host_route.location);
router = router.route(route_path.as_ref(), get(reverse_proxy::serve));
// Set request max body size
if let Some(max_body_size) = host_route.max_body_size {
router = router.layer(DefaultBodyLimit::max(max_body_size as usize));
}
// resister with location
// location = "/doc"
// route: GET /doc/*
// resister with file path
// index = ["index.html", "index.txt"]
// route: GET /doc/index.html
// route: GET /doc/index.txt
// register parent path /doc
let path_morethan_one = host_route.location.len() > 1;
let route_path = if path_morethan_one && host_route.location.ends_with('/') {
// first register path with slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
let len = host_route.location.len();
let path_without_slash = host_route.location.chars().collect::<Vec<_>>()
[0..len - 1]
.iter()
.collect::<String>();
// then register path without slash /doc/
router = router.route(&path_without_slash, get(serve::serve));
debug!("registed route {}", path_without_slash);
host_route.location.clone()
} else if path_morethan_one {
// first register path without slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
// then register path with slash /doc/
let path = format!("{}/", host_route.location);
router = router.route(&path, get(serve::serve));
debug!("registed route {}", path);
path
} else {
// register path /doc/
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
host_route.location.clone()
};
// save route path to map
{
host_to_save
.route_map
.insert(route_path.clone(), host_route.clone());
.insert(host_route.location.clone(), host_route.clone());
}
let route_path = format!("{route_path}{{*path}}");
// register wildcard path /doc/*
router = router.route(route_path.as_ref(), get(serve::serve));
debug!("registed route: {}", route_path);
continue;
}
// static file
if host_route.root.is_none() {
warn!("root field not found for route: {:?}", host_route.location);
continue;
}
// Set request max body size
if let Some(max_body_size) = host_route.max_body_size {
router = router.layer(DefaultBodyLimit::max(max_body_size as usize));
}
// resister with location
// location = "/doc"
// route: GET /doc/*
// resister with file path
// index = ["index.html", "index.txt"]
// route: GET /doc/index.html
// route: GET /doc/index.txt
// register parent path /doc
let path_morethan_one = host_route.location.len() > 1;
let route_path = if path_morethan_one && host_route.location.ends_with('/') {
// first register path with slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
let len = host_route.location.len();
let path_without_slash = host_route.location.chars().collect::<Vec<_>>()[0..len - 1]
.iter()
.collect::<String>();
// then register path without slash /doc/
router = router.route(&path_without_slash, get(serve::serve));
debug!("registed route {}", path_without_slash);
host_route.location.clone()
} else if path_morethan_one {
// first register path without slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
// then register path with slash /doc/
let path = format!("{}/", host_route.location);
router = router.route(&path, get(serve::serve));
debug!("registed route {}", path);
path
} else {
// register path /doc/
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
host_route.location.clone()
};
// save route path to map
{
host_to_save
.route_map
.insert(route_path.clone(), host_route.clone());
}
let route_path = format!("{route_path}{{*path}}");
// register wildcard path /doc/*
router = router.route(route_path.as_ref(), get(serve::serve));
debug!("registed route: {}", route_path);
}
// save host to map

View File

@ -157,21 +157,30 @@ pub async fn serve(
let scheme = req.uri().scheme_str().unwrap_or("http");
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
let route_map = &HOSTS.get(&port).ok_or(RouteError::BadRequest())?.route_map;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
tracing::debug!("Route map entries: {:?}", route_map);
let parent_path = resolve_parent_path(&req_uri, path.as_ref());
tracing::debug!("parent path: {:?}", parent_path);
let proxy_config = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())?;
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
tracing::debug!("proxy pass: {:?}", proxy_config);
let Some(ref proxy_pass) = proxy_config.proxy_pass else {
return handle_custom_page(proxy_config, req, true).await;
};
let uri = format!("{proxy_pass}{path_query}");
tracing::debug!("reverse proxy uri: {:?}", &uri);
*req.uri_mut() = Uri::try_from(uri.clone()).map_err(|_| RouteError::InternalError())?;
*req.uri_mut() = Uri::try_from(uri.clone())
.map_err(|_| RouteError::InternalError())
.with_context(|| format!("uri not found: {uri}"))?;
let timeout = proxy_config.proxy_timeout;
@ -188,7 +197,6 @@ pub async fn serve(
// forward request body
let body = req.into_body();
// TODO: set body size limit
let bytes = axum::body::to_bytes(body, 2048).await.map_err(|err| {
tracing::error!("Failed to proxy request: {}", err);
RouteError::InternalError()
@ -211,7 +219,8 @@ pub async fn serve(
reqwest_response.headers(),
response_builder
.headers_mut()
.ok_or(RouteError::InternalError())?,
.ok_or(RouteError::InternalError())
.with_context(|| "headers not found")?,
);
let res = response_builder
.body(Body::from_stream(reqwest_response.bytes_stream()))

View File

@ -15,7 +15,7 @@ use axum_extra::extract::Host;
use dashmap::mapref::one::Ref;
use http::{
HeaderMap, HeaderValue, StatusCode, Uri,
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH},
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION},
};
use mime_guess::from_path;
use tokio::fs::{self, File};
@ -52,30 +52,34 @@ async fn custom_page(
host_route
.error_page
.as_ref()
.ok_or(RouteError::RouteNotFound())?
.ok_or(RouteError::RouteNotFound())
.with_context(|| "error page not found")?
} else {
host_route
.not_found_page
.as_ref()
.ok_or(RouteError::RouteNotFound())?
.ok_or(RouteError::RouteNotFound())
.with_context(|| "not found page not found")?
};
let root = host_route
.root
.as_ref()
.ok_or(RouteError::InternalError())?;
.ok_or(RouteError::InternalError())
.with_context(|| "root not found")?;
let path = format!("{}/{}", root, page.page);
let status = StatusCode::from_str(page.status.to_string().as_ref())
.map_err(|_| RouteError::BadRequest())?;
.map_err(|_| RouteError::BadRequest())
.with_context(|| format!("status code not found: {}", page.status))?;
tracing::debug!("custom not found path: {:?}", path);
match stream_file(path.into(), request, Some(status)).await {
Ok(res) => RouteResult::Ok(res),
Err(e) => {
println!("Failed to stream file: {:?}", e);
error!("Failed to stream file: {:?}", e);
RouteResult::Err(RouteError::InternalError())
}
}
@ -125,11 +129,18 @@ pub async fn serve(
// which is `host_route.location`
let scheme = request.uri().scheme_str().unwrap_or("http");
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
let route_map = &HOSTS.get(&port).ok_or(RouteError::BadRequest())?.route_map;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
debug!("Route map entries: {:?}", route_map);
let host_route = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())?;
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
debug!("route: {:?}", host_route);
// after route found
// check static file root configuration
@ -149,7 +160,7 @@ pub async fn serve(
#[allow(clippy::unnecessary_to_owned)]
let path = path.to_string();
if path.contains('.') {
(root.into(), vec![format!("{}/{}", root, path)])
(root.into(), vec![format!("{}{}", root, path)])
} else {
generate_default_index(&host_route, &format!("{root}/{path}"))
}
@ -157,6 +168,7 @@ pub async fn serve(
generate_default_index(&host_route, root)
};
debug!("request index file {:?}", path_arr);
debug!("req_path: {:?}", req_path);
// Try each candidate path in order:
// - Return the first successfully streamed file.
// - If all fail, return a `RouteNotFound` error.
@ -172,6 +184,26 @@ pub async fn serve(
let path_exists = match path_exists {
Some(path_exists) => path_exists,
None => {
let uri_path = uri.path();
// 如果请求路径不以 / 结尾,则返回 301 Moved Permanently 状态码
if !uri_path.ends_with('/') {
let mut response = Response::builder();
let stream = empty_stream().await?;
let body = Body::from_stream(stream);
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
LOCATION,
HeaderValue::from_str(format!("{uri_path}/").as_str())
.with_context(|| "insert header failed")?,
);
response = response.status(StatusCode::MOVED_PERMANENTLY);
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with body")?;
return Ok(response);
}
// 生成自动目录索引
if host_route.auto_index {
// HTML 中的标题路径,需要移除掉配置文件中的 root = "./html" 字段
@ -267,16 +299,8 @@ async fn stream_file(
}
}
#[cfg(windows)]
let null = PathBuf::from("NUL");
#[cfg(not(windows))]
let null = PathBuf::from("/dev/null");
let stream = if not_modified {
let empty = File::open(null)
.await
.with_context(|| "open /dev/null failed")?;
ReaderStream::new(empty)
empty_stream().await?
} else {
ReaderStream::new(file)
};
@ -598,7 +622,11 @@ async fn list_dir(host_root_str: &str, path: &PathBuf) -> anyhow::Result<Vec<Dir
.strip_prefix(&host_root_str)
.ok_or(anyhow!("strip prefix failed"))?
.to_string();
let path = format!("./{path}");
let path = if is_dir {
format!("./{path}/")
} else {
format!("./{path}")
};
// 创建并返回目录条目信息
let dir = DirList {
name,
@ -619,3 +647,24 @@ async fn list_dir(host_root_str: &str, path: &PathBuf) -> anyhow::Result<Vec<Dir
Ok(list)
}
/// 创建一个空数据流,用于返回空响应或占位数据
///
/// 在不同操作系统上,会自动选择对应的空设备文件:
/// - Windows: NUL
/// - Unix/Linux: /dev/null
///
/// 返回一个异步流,内容为一个空文件的数据流
///
/// # 错误处理
/// 如果无法打开空设备文件,会返回带有上下文信息的错误
pub async fn empty_stream() -> anyhow::Result<ReaderStream<File>> {
#[cfg(windows)]
let null = PathBuf::from("NUL");
#[cfg(not(windows))]
let null = PathBuf::from("/dev/null");
let empty = File::open(null)
.await
.with_context(|| "open /dev/null failed")?;
Ok(ReaderStream::new(empty))
}

View File

@ -71,7 +71,6 @@ pub async fn add_headers(Host(host): Host, req: Request, next: Next) -> impl Int
debug!("port {:?}", port);
let mut res = next.run(req).await;
let req_headers = res.headers_mut();
// let host = HOSTS.read().await;
let Some(host) = HOSTS.get(&port) else {
return res;
};