http_server
本地 HTTP 文件服务器。
http_server 是按 Python/MiniApp 运行会话隔离的原生静态文件服务器。它只绑定 127.0.0.1,以恒定内存分块提供授权目录中的普通文件,支持 GET、HEAD 和单个 HTTP Byte Range。每次启动都会生成新的高熵访问令牌;没有令牌的本机请求也只能得到 404。
边界:这是前台、会话内的 loopback 文件服务,不是局域网或公网 Web Server,也不是后台传输任务。MiniApp 停止、会话过期或调用 stop() 时,listener 和全部连接都会被取消。
声明式 MiniApp 同时需要网络与文件能力:
{
"capabilities": ["network", "file_system"]
}
network 允许创建 loopback listener,file_system 允许读取服务根目录;两者不是二选一。
#模块概览
| 项 | 说明 |
|---|---|
| 导入 | import http_server |
| 通用地址 | http://127.0.0.1:<port>/<token>/;URLSession、WebKit 与普通 HTTP 客户端均可使用 |
| 浏览器地址 | http://<token>.localhost:<port>/;用于 AppUI WebView/Safari,并支持 /style.css 这类根相对资源 |
| 方法 | GET、HEAD |
| Range | 单个 bytes= 范围;不支持 multipart ranges |
| 根目录 | MiniApp 默认当前工作区;普通脚本保留 Documents 默认值 |
| 文件 | 只提供普通文件;不列目录,不跟随符号链接 |
| 流式 | 64 KiB 文件块,发送完成后才读取下一块,内存不随文件大小增长 |
| 生命周期 | 每个运行 owner 一台 server;重复 start() 串行停止旧 server 后启动新 server |
#快速开始
推荐使用 port=0 让系统选择空闲端口:
import http_server
try:
url = http_server.start(port=0) # 默认 url_mode="universal"
print("服务地址:", url)
print(http_server.status())
finally:
http_server.stop()
若返回 http://127.0.0.1:49152/4f8c.../,文件 report.pdf 的地址就是:
http://127.0.0.1:49152/4f8c.../report.pdf
请相对返回值追加文件名,不要自行去掉令牌或用以 / 开头的路径覆盖它。返回地址的逻辑根 GET/HEAD 是健康检查,不会列出目录内容。
#AppUI 示例
启动、停止和状态查询放在用户回调中,不要放进 body():
import appui
import http_server
state = appui.State(
running=False,
url="—",
requests="0",
status="点击启动本地服务",
)
def refresh_status():
try:
info = http_server.status() or {}
state.batch_update(
running=bool(info.get("running")),
url=info.get("url") or "—",
requests=str(info.get("requests_served", 0)),
)
except http_server.HttpServerError as exc:
state.status = f"查询失败: {exc} ({exc.code})"
def start_server():
try:
url = http_server.start(port=0)
refresh_status()
state.status = f"已启动: {url}"
except http_server.HttpServerError as exc:
state.status = f"启动失败: {exc} ({exc.code})"
def stop_server():
try:
http_server.stop()
refresh_status()
state.status = "已停止"
except http_server.HttpServerError as exc:
state.status = f"停止失败: {exc} ({exc.code})"
def body():
return appui.NavigationStack(
appui.Form([
appui.Section("服务状态", [
appui.LabeledContent("运行中", value="是" if state.running else "否"),
appui.LabeledContent("地址", value=state.url),
appui.LabeledContent("已完成请求", value=state.requests),
]),
appui.Section("操作", [
appui.Button(
"停止服务" if state.running else "启动服务",
action=stop_server if state.running else start_server,
).button_style("bordered_prominent"),
appui.Button("刷新状态", action=refresh_status),
appui.Text(state.status).foreground_color("secondaryLabel"),
], footer="仅绑定 127.0.0.1;MiniApp 停止时自动清理。"),
]).navigation_title("HTTP 服务")
)
appui.run(body, state=state)
若要让本地 HTML、CSS、JavaScript、媒体使用常规 Web 根路径,显式请求浏览器地址:
browser_url = http_server.start(port=0, url_mode="browser")
preview = appui.WebView(url=browser_url + "index.html")
返回的 browser_url 是服务基地址;它的根路径保留为 JSON 健康检查,所以预览网页时必须追加真实入口文件(例如 index.html)。宿主只为当前运行会话注册两段相互绑定的精确访问范围:浏览器地址的 scheme + token host + port,以及通用地址的 127.0.0.1 + port + /token/ path prefix。两种地址都可交给 AppUI WebView,无需把动态令牌写进 allowedNetworkHosts,也不会放开普通 http://localhost 或该端口的其他路径;stop()、重新启动或会话结束后授权立即失效。含 /style.css 这类根相对资源的站点仍应选择 browser。
#API 参考
#start(port=8080, root_dir=None, *, url_mode="universal")
启动当前运行会话的 server,并按 url_mode 返回一种经过令牌保护的 URL。
url = http_server.start(port=0)
url = http_server.start(port=18080, root_dir="/path/to/folder")
browser_url = http_server.start(port=0, url_mode="browser")
# 网页入口:browser_url + "index.html"
| 参数 | 说明 |
|---|---|
port | 0...65535;0 由系统选择可用端口,默认保留为 8080 以兼容旧脚本 |
root_dir | 已存在且已授权的目录;支持字符串和 os.PathLike |
url_mode | 仅可为 "universal" 或 "browser";keyword-only,默认 "universal" |
对同一运行再次调用 start() 时,只影响该运行拥有的旧 server,不会停止其他 MiniApp/脚本会话的 server。替换请求从进入 Python facade 起即采用 fail-closed 语义:端口、路径对象或 url_mode 的 Python 预校验失败,也会停止并撤销旧 server;通过预校验后,原生端仍会在自己的 owner 串行门内先停止旧 server,再校验根目录并启动新 listener。新 listener 必须成功进入 ready 状态后才返回真实端口。任何阶段失败时,该运行都保持停止状态,不会恢复已经撤销的旧 server。
#stop()
幂等停止当前运行的 server,并中断 listener、活动连接、流读取和会话资源注册。重复调用安全。
#status()
保留旧字段并增加有界诊断:
| 字段 | 说明 |
|---|---|
running | 当前运行是否拥有 ready server |
port | 实际绑定端口;停止时为 0 |
url | loopback URL;停止时为 None |
browser_url | 当前 server 的精确 localhost 浏览器 origin;仅运行时提供 |
root_dir | 已授权、已解析的根目录 |
server_id | 当前 server 实例 ID;运行时提供 |
active_connections | 当前活动连接数 |
requests_served | 已完整发送的请求数 |
bytes_sent | 已确认发送的 HTTP 字节数 |
started_at | ISO 8601 启动时间;运行时提供 |
bind_host | 固定为 127.0.0.1 |
range_policy | 固定为 single |
#HttpServerError
原生启动、权限或协议桥错误会抛出 HttpServerError。请按 exc.code 分支,不要匹配本地化消息。
#HTTP 行为
| 请求 | 结果 |
|---|---|
对返回 URL 执行 GET | 200 JSON 健康响应 |
对返回 URL 执行 HEAD | 与健康响应相同 headers,不发送 body |
返回 URL + path/file.bin | 200,流式发送普通文件 |
对文件 URL 执行 HEAD | 200 与准确 Content-Length,无 body |
GET + Range: bytes=100-199 | 206 + Content-Range,流式发送一个范围 |
| 无效、越界或多个 Range | 416 + Content-Range: bytes */size |
| 其他方法 | 405 + Allow: GET, HEAD |
| 文件缺失 | 404 JSON 错误 |
支持 bytes=start-end、bytes=start- 和 bytes=-suffixLength。只实现一个 range 是通用的确定性资源策略,不是面向某类 MiniApp 的补丁;视频预览、断点读取、PDF 和大文件客户端都能复用。
成功与错误的 HEAD 都不会发送 body。每个连接只处理一个请求并明确 Connection: close;最后一个响应块使用 Network.framework 的 final context 发送 TCP FIN,并在最多 2 秒的有界收尾窗口后强制回收异常客户端。常见扩展名会返回原生 MIME;未知类型为 application/octet-stream,同时发送 X-Content-Type-Options: nosniff。
#安全与资源边界
- listener 的必需本地端点固定为
127.0.0.1;不是0.0.0.0,也不会公布 LAN 地址。 - 通用 URL 以系统安全随机数生成的 32 位十六进制临时令牌作为首个 path segment;浏览器 URL 以同一令牌作为精确
.localhost子域。未授权 origin/path 返回404,不会暴露目录是否存在。 - 两种 URL 的 AppUI WebView 放行都由当前 native lease 精确注册,并由 Python→IR 前置策略和 Swift/WebKit 最终策略分别校验;它不能泛化为任意 localhost、其他 token path 或端口,停止、替换 server 或会话撤销时同步撤销。
root_dir必须是当前运行的工作区或授权只读根;声明式 MiniApp 必须同时具有network与file_system。- URL 百分号解码后再验证;拒绝空组件、
.、..、反斜杠、NUL 和越界路径。路径最多 128 个组件,每个组件最多 255 个 UTF-8 字节。 - 声明式 MiniApp 的工作区/临时只读根会在 Python 启动前由宿主打开并随 native lease 持有目录 descriptor;
start()只从该可信 descriptor 按 lexical 相对组件继续openat(..., O_NOFOLLOW),授权与打开不是两个可被竞态插入的步骤。iOS 的系统/var//tmpalias 只在宿主建 lease 时规范化,MiniApp 可控的子路径不会先做 realpath。 - 每一级请求路径继续使用目录 descriptor 与
openat(..., O_NOFOLLOW);最终对象必须是单链接普通文件。目录、FIFO、设备、socket、symlink 和 hard link 均不会被提供。 - 请求 headers 上限 64 KiB,header 数量有界;完整 header 还必须在 10 秒绝对期限内到达。
- 每台 server 最多 32 个活动连接;连接空闲/发送停滞约 30 秒取消。
- 完整响应以 TCP FIN 优雅关闭写端;对端未及时结束时,2 秒后释放连接槽,不会无限等待。
- 文件按 64 KiB 分块,并等待 Network.framework 的发送完成回调后读取下一块。
- 已发送
200/206headers 后若文件被截断或读失败,会关闭连接,不会再写第二套500headers 污染响应。 - MiniApp 会话过期会阻止后续读取并关闭 server;旧运行无法借新运行继续访问文件。
- URL 中的令牌属于当前会话的临时访问凭据;不要持久化、公开分享或写入长期日志。
#常见错误码
HttpServerError.code | 含义 |
|---|---|
invalid_input | 端口或 bridge 请求无效 |
invalid_path | 请求中的路径参数无效 |
invalid_root | root_dir 不存在、不是目录或无法安全打开 |
symlink_not_allowed | 根目录或请求路径包含不允许的符号链接 |
file_access_denied | 根目录不在当前运行授权范围 |
network_not_allowed | 当前会话未获网络/server 权限 |
port_in_use | 指定端口已占用 |
server_limit | 达到宿主的并发 server 上限 |
startup_timeout / listener_unavailable / listener_cancelled | listener 未能进入 ready 或启动期间已被撤销 |
miniapp_session_expired | 启动期间或运行期间会话已过期 |
unavailable | 底层 Network.framework 启动失败 |
bridge_unavailable / empty_response / invalid_native_response | Python 与原生 bridge 不可用或返回不符合契约 |
HTTP 请求级错误以 HTTP status 和 JSON code 返回给客户端,不会作为 Python HttpServerError 抛出。
#常见错误
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 start() | SwiftUI/AppUI 重建时重复启动 | 放进按钮回调 |
| 固定一个热门端口 | 容易出现 port_in_use | 优先 port=0 |
只声明 network | 没有读取根目录的文件能力 | 同时声明 network 与 file_system |
| 把它当 LAN 服务器 | 127.0.0.1 不接受远端连接 | 远程传输使用 SSH 或经审核的网络方案 |
| 用它代替后台下载/上传 | App/会话停止后 server 被清理 | 使用 background_download 等持久任务 API |
| 自己拼接未编码路径 | 空格、Unicode 或 # 产生错误 URL | 对每个 URL path segment 做百分号编码 |
用 "/file" 覆盖通用 URL 的 path | 丢失访问令牌并收到 404 | 使用相对 segment,或为网页选择 url_mode="browser" |
把 browser_url 保存到下次运行 | token 与 server 生命周期已结束 | 每次运行重新调用 start() |
#相关文档
| 文档 | 用途 |
|---|---|
| network | HTTP 客户端、上传下载和流式响应 |
| background_download | 可恢复的后台文件下载 |
| archive | 下载或分享 ZIP/IPA 后原生处理 |
| MiniApp 原生能力 | network + file_system 能力与会话规则 |
#预期效果
旧的 start() / stop() / status() 写法保持可用;新代码可用 port=0、HEAD、Range 和诊断字段。无论文件多大,正常传输内存都保持有界;路径逃逸、symlink、hard link、非普通文件、过量 headers、连接过多和旧会话都会被原生内核拒绝或清理。