bridge4
v0.2.0
Published
Localhost bridge: serve a directory over WebDAV + SQLite query API with an Ink TUI
Maintainers
Readme
Bridge4
把本地一个目录临时"服务化"的 localhost 桥接工具:为本地 Web 页面提供 WebDAV 文件访问 和 SQLite 查询 API,并带一个 Ink TUI 监控界面。
定位:本地开发时,让浏览器里的 Web 应用能快速读写本地文件、查询本地 sqlite 数据库,而无需搭后端。
- 语言/运行时:TypeScript / Node >= 20 / ESM
- 绑定:只监听
127.0.0.1(信任边界 = 本机) - 默认模式:全只读;客户端可申请升级为读写(需 TUI 内人工确认)
快速开始
pnpm install
pnpm dev # tsx 直接跑
pnpm build && pnpm start # 编译后跑
node dist/cli.js [路径] [--port N] # 默认路径=cwd,默认端口=17894usage: bridge4 [path] [--port N]按 q 退出(优雅关闭),Ctrl+C 亦可。
路由总览
| 路由 | 方法 | 说明 |
|---|---|---|
| /dav/* | WebDAV 全集 | RFC 4918 完整方法(见下) |
| /api/query | POST | 对 sqlite 文件执行 SQL(只读/读写随当前模式) |
| /api/escalate | POST | 申请读写权限(阻塞式 long-poll) |
| /api/status | GET | 服务器状态 JSON |
| /api/version | GET / HEAD | 版本与 API 兼容级别(轻量探测,带 Cache-Control: no-store) |
| 其他 | - | 404 JSON |
全局行为:
- CORS 全放开:
Access-Control-Allow-Origin: *,预检(带Access-Control-Request-Method的 OPTIONS)返回 204;普通 OPTIONS 照常进入 WebDAV 处理(返回DAV: 1, 2头)。 - 所有响应携带
Access-Control-Expose-Headers: x-bridge4-hint, Lock-Token, ETag,浏览器 JS 可读取这些头。 - 错误统一为 JSON:
{"ok": false, "error": "..."};WebDAV 内部错误为 XML<D:error>。 - 只读模式下被拦截的写操作额外返回
x-bridge4-hint: POST /api/escalate头,提示客户端如何升级。
安全模型(核心设计)
- 进程启动即为 read-only:文件系统写类 WebDAV 方法一律 403;sqlite 以
readonly + fileMustExist物理只读打开。 - 客户端
POST /api/escalate申请升级:请求挂起最多 60s;TUI 弹出提示,用户按y/n。 - 通过后是全局模式切换(非按客户端 token):整个服务(WebDAV + sqlite)进入读写,直到:
- TUI 按
x主动撤销,或 - 进程退出/重启(自动回落只读)。
- TUI 按
- 无鉴权、无写保护规则——本机信任模型下这是刻意取舍。
client TUI(user)
| POST /api/escalate |
| (挂起 ≤60s) ----------------> | 弹提示: y / n
| <-------------- 200 {mode:rw}| y: 全局切 RW
| 或 403 (拒绝/超时/已有 pending)| n: 403注意:升级是全局的——任何能连 127.0.0.1 的网页此时都能写。TUI 顶部会显示常驻红色警示条。
Client 开发指南
Base URL:http://127.0.0.1:17894(端口以 /api/status 实际返回为准)。下文用 $B 代指。
通用约定
- 路径段必须 URL 编码(中文/空格/特殊字符):
/dav/我的 文件.txt→/dav/%E6%88%91%E7%9A%84%20%E6%96%87%E4%BB%B6.txt。 - 目录的 href 以
/结尾,文件不带。 - 收到
403且带x-bridge4-hint头 ⇒ 只读拦截,不是权限错误,走升级流程后重试原请求。
GET /api/status — 能力发现
curl -s $B/api/status{
"name": "bridge4",
"version": "0.1.0",
"api": 1,
"mode": "ro",
"root": "C:\\Users\\me\\project",
"port": 17894,
"urls": {
"webdav": "http://127.0.0.1:17894/dav/",
"query": "http://127.0.0.1:17894/api/query",
"escalate": "http://127.0.0.1:17894/api/escalate"
}
}mode 只有 ro/rw 两个值。建议 client 启动时先探测一次,写操作前若 mode !== "rw" 先走升级。
GET /api/version — 版本与兼容性判断
curl -s $B/api/version{ "name": "bridge4", "version": "0.1.0", "api": 1 }version:语义化版本(如0.1.0),仅作展示与日志用,不要用它判断兼容性。api:整数兼容级别。仅当 HTTP 接口出现破坏性变更(删除/重命名端点、修改已有字段含义、改变错误码语义)时才 +1;新增端点、新增响应字段、行为细化都不会 bump。- client 约定:请求该接口(响应带
Cache-Control: no-store,不会被缓存),若api < 你所需的最低级别则提示用户升级 bridge4,而不是逐个特性去试错。/api/status的响应里也带同样的api字段,探测 status 时无需额外请求。
POST /api/query — SQL 查询
请求
POST /api/query HTTP/1.1
Content-Type: application/json
{
"path": "data/sample.db",
"sql": "SELECT * FROM notes WHERE id = ?",
"rowLimit": 1000
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| path | string | 是 | root 内相对路径(不是绝对路径),如 data/sample.db、./x.db |
| sql | string | string[] | 是 | 单条 SQL,或多条 SQL 数组(见下) |
| rowLimit | number | 否 | 每条语句最多返回行数,默认 1000,正整数 |
多语句语义:
- 数组内每条元素是一条独立 SQL,在同一事务中顺序执行。
- 任一语句失败 ⇒ 整个事务回滚,
results不返回,错误里带失败语句的下标。 - 服务端不做 SQL 字符串切分,多语句必须用数组传。
成功响应 200
{
"ok": true,
"mode": "ro",
"results": [
{
"statement": 0,
"columns": ["id", "body"],
"rows": [{ "id": 1, "body": "first note" }]
},
{
"statement": 1,
"changes": 1,
"lastInsertRowid": 2
}
],
"tookMs": 9
}results[i] 按语句类型二选一:
- 查询类(SELECT/PRAGMA/...,sqlite3_stmt 是 reader):
statement+columns(列名数组)+rows(对象数组)。 - 变更类(INSERT/UPDATE/DELETE/DDL):
statement+changes(影响行数)+lastInsertRowid。 - DDL(CREATE TABLE 等)归变更类,
changes通常为 0。
值编码约定:
- 整数默认是 JS number;超过
Number.MAX_SAFE_INTEGER转为十进制字符串。 - 浮点/文本/NULL 按 JSON 原生表示。
- BLOB 编码为
{ "$binary": "<base64>" }。
错误响应
{ "ok": false, "error": "statement 1: no such table: missing_table" }| 状态码 | 触发条件 | 备注 |
|---|---|---|
| 400 | JSON 解析失败 / path、sql 缺失或为空 / SQL 语法或运行时错误 / path 指向目录 | SQL 错误带 statement i: 前缀,便于定位是哪条失败 |
| 403 | path 越出 root;只读模式下被拦截时也会带 x-bridge4-hint 头 | |
| 404 | path 文件不存在(fileMustExist) | |
| 413 | 单条语句返回行数超过 rowLimit | 报错而非截断,错误里带实际行数 |
| 503 | SQLITE_BUSY(默认 5s busy timeout 内拿不到锁) | 可安全重试 |
只读模式下执行写 SQL,SQLite 原生的报错文本为 attempt to write a readonly database——client 可用 /read-only|readonly/i 识别,或直接看 x-bridge4-hint 头。
JS 调用示例
async function query(path: string, sql: string | string[], rowLimit?: number) {
const res = await fetch("http://127.0.0.1:17894/api/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, sql, rowLimit }),
});
const body = await res.json();
if (!res.ok || !body.ok) {
throw new QueryError(res.status, body.error, res.headers.get("x-bridge4-hint"));
}
return body; // { mode, results, tookMs }
}POST /api/escalate — 申请读写
请求与响应
POST /api/escalate HTTP/1.1无请求体。响应三种:
// 200:已是 RW,或本次申请被 TUI 批准
{ "ok": true, "mode": "rw" }
// 403:被拒 / 60s 超时 / 同时已有另一个申请挂起(一进程同时只挂一个)
{ "ok": false, "mode": "ro", "hint": "denied or timed out; try again" }已有 pending 时新申请立即返回 403(不排队)。client 应对 403 做指数退避重试。
推荐 client 流程
async function withWriteAccess<T>(fn: () => Promise<T>): Promise<T> {
// 1. 先直接试;被只读拦截再走升级,省一次往返
try {
return await fn();
} catch (err) {
if (!(err instanceof QueryError) || !err.hint) throw err;
}
// 2. 申请升级(挂起直到 TUI 决定,最多 60s)
const res = await fetch("http://127.0.0.1:17894/api/escalate", { method: "POST" });
if (!res.ok) throw new Error("read-write access denied (user rejected or timed out)");
// 3. 重试原操作
return fn();
}注意:
- 升级生效是全局的——你的申请通过后,其他页面也获得了写能力;撤销只能由 TUI 用户按
x完成,client 无法主动降级。 - 升级不持久:服务重启后回到只读,client 不应缓存"已升级"状态超过必要时间,建议每次写会话前以
/api/status或写操作本身的结果为准。
GET/POST 之外的 WebDAV —— /dav/*
URL 路径即 root 内相对路径。root 本身 = /dav/。
OPTIONS — 能力探测
curl -s -i -X OPTIONS $B/dav/HTTP/1.1 200 OK
DAV: 1, 2
MS-Author-Via: DAV
Allow: OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCKDAV: 1, 2 表示支持 locking(class 2 client 依赖它做独占编辑)。
GET / HEAD — 读文件(支持 Range)
curl -s $B/dav/hello.txt # 全文
curl -s -H "Range: bytes=2-5" $B/dav/range.bin # 206 分段
curl -s -H "Range: bytes=-3" $B/dav/range.bin # 后缀分段
curl -s -I $B/dav/hello.txt # HEAD 同 GET 无 bodyHTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 14
Accept-Ranges: bytes
ETag: W/"14-1760280811461"
Last-Modified: Mon, 12 Oct 2026 12:53:31 GMT| 状态码 | 触发 |
|---|---|
| 200 | 正常 |
| 206 | 单段 Range 成功(响应含 Content-Range: bytes 2-5/10) |
| 416 | Range 非法(越界/空段),响应含 Content-Range: bytes */<size> |
| 404 | 文件不存在 / 路径是目录 / 路径是隐藏元文件 |
多段 Range(bytes=0-1,4-5)不支持,回退 200 全量。
PROPFIND — 元数据/列目录
三种请求体形态(Content-Type: application/xml;空 body = allprop):
<!-- allprop:所有活属性 + 死属性 -->
<D:propfind xmlns:D="DAV:"><D:allprop/></D:propfind>
<!-- propname:只列属性名 -->
<D:propfind xmlns:D="DAV:"><D:propname/></D:propname>
<!-- prop:只取指定属性(含自定义死属性) -->
<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/><D:getcontentlength/><Z:tags xmlns:Z="urn:x"/></D:prop></D:propfind>Depth 头:0 只查资源本身,1 查本身+直接子级(默认),infinity → 403(本服务拒绝)。
列目录示例:
curl -s -X PROPFIND -H "Depth: 1" $B/dav/ | xmllint --format -<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dav/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:displayname>/</D:displayname>
<D:getlastmodified>Mon, 12 Oct 2026 12:53:31 GMT</D:getlastmodified>
<D:creationdate>2026-10-12T12:53:24.680Z</D:creationdate>
<D:lockdiscovery/>
<D:supportedlock>
<D:lockentry><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>
<D:lockentry><D:lockscope><D:shared/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>
</D:supportedlock>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
<D:propstat>
<D:prop><D:getcontentlength/><D:getcontenttype/><D:getetag/></D:prop>
<D:status>HTTP/1.1 404 Not Found</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dav/hello.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:displayname>hello.txt</D:displayname>
<D:getcontentlength>14</D:getcontentlength>
<D:getlastmodified>Mon, 12 Oct 2026 12:53:31 GMT</D:getlastmodified>
<D:creationdate>2026-10-12T12:53:31.461Z</D:creationdate>
<D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
<D:getetag>W/"14-1760280811461"</D:getetag>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>解析要点:
- 区分文件/目录看
resourcetype里有没有<collection/>;目录 href 带尾/。 - 每个
D:response可能有多个 propstat:按状态码分组(200 一组、404 一组)。prop 不存在 ≠ 请求失败。 - 目录没有
getcontentlength/getcontenttype/getetag(404 propstat),不要假设它们存在。 - 活属性全集:
resourcetype, displayname, getcontentlength, getlastmodified, creationdate, getcontenttype, getetag, lockdiscovery, supportedlock;其他名字都是死属性。 - root 下的
.bridge4.props.json(死属性存储)不出现在列表里,直接 GET 也 404——client 遍历目录时不会看到它。 - 探测 sqlite 文件:列目录后按扩展名过滤
.db/.sqlite/.sqlite3即可(服务不提供 db 枚举接口,刻意设计)。
| 状态码 | 触发 |
|---|---|
| 207 | 正常(正文 multistatus) |
| 403 | Depth: infinity |
| 404 | 资源不存在 |
PUT — 写文件
curl -s -o /dev/null -w "%{http_code}\n" -X PUT --data-binary @local.bin \
-H "Content-Type: application/octet-stream" $B/dav/dir/local.bin| 状态码 | 触发 |
|---|---|
| 201 | 新建成功 |
| 204 | 覆盖已存在文件成功 |
| 403 | 只读模式 / 目标是保留名 .bridge4.props.json |
| 409 | 父目录不存在(不会自动建目录,先 MKCOL) |
| 405 | 目标是目录 |
| 423 | 资源被他人锁定(响应带 Lock-Token 头) |
| 501 | 请求带 Content-Range(不支持分段 PUT) |
- body 大小无限制(流式落盘),空 body 合法(写成 0 字节文件)。
- 覆盖前建议带
If: (<你的锁token>)(见 LOCK 节),否则对锁定文件写会 423。
MKCOL — 建目录
| 状态码 | 触发 | |---|---| | 201 | 成功 | | 405 | 已存在(含 lock-null 占位但你不是持有者) | | 409 | 父目录不存在 | | 415 | 带请求体(不支持) |
DELETE — 删文件/递归删目录
| 状态码 | 触发 | |---|---| | 204 | 成功(目录递归删除,无回收站) | | 404 | 不存在 | | 423 | 自身或子树内有锁(他人持有)/ 子树内有 lock-null 占位 |
删除会同时清理该子树的死属性与锁记录。
COPY / MOVE
核心在 Destination 头(绝对 URL 或绝对路径,都必须指向本服务的 /dav 命名空间,跨服务 → 502):
curl -s -o /dev/null -w "%{http_code}\n" -X COPY \
-H "Destination: http://127.0.0.1:17894/dav/backup/copy.txt" \
$B/dav/dir/copy.txt| 状态码 | 触发 |
|---|---|
| 201 | 目标新建成功 |
| 204 | 覆盖已存在目标成功 |
| 412 | 目标已存在且 Overwrite: F |
| 423 | 源/目标被他人锁定(MOVE 的源在子树有锁时也 423) |
| 409 | 目标父目录不存在;目录 COPY/MOVE 进自己的子树 |
| 502 | Destination 指向本服务之外 |
语义细节:
Overwrite头默认T;覆盖目标前会先递归删目标。- COPY 忽略 Depth 头,始终递归整树(含死属性复制;锁不复制)。
- MOVE 忽略 Depth 头,始终按 infinity 语义;同盘 rename,跨盘自动 copy+delete;锁记录与死属性迁移到新路径。
- COPY 源是 lock-null 占位 → 404。
LOCK / UNLOCK — 锁
加锁请求体(scope 只有 exclusive/shared,type 只有 write):
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner><D:href>my-app</D:href></D:owner>
</D:lockinfo>curl -s -i -X LOCK -H "Depth: 0" -H "Content-Type: application/xml" \
-d "$LOCKINFO" $B/dav/doc.mdHTTP/1.1 200 OK # 已存在资源加锁
Lock-Token: <opaquelocktoken:550e8400-e29b-41d4-a716-446655440000>
Timeout: Second-3600
<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:exclusive/></D:lockscope>
<D:depth>0</D:depth>
<D:owner><D:href>my-app</D:href></D:owner>
<D:timeout>Second-3599</D:timeout>
<D:locktoken><D:href>opaquelocktoken:...</D:href></D:locktoken>
</D:activelock></D:lockdiscovery></D:prop>锁模型要点:
- Depth:
0(仅该资源)或infinity(缺省;集合锁覆盖整个子树,含未来新建成员)。 - 过期:默认 1h;请求头
Timeout: Second-N可指定(N ≤ 86400)。过期惰性回收。响应固定回Timeout: Second-3600。 - 续期:对已锁资源带
If: (<token>)再发 LOCK(body 可省略)→ 200 并刷新过期时间。 - lock-null:对不存在的路径加锁必须
Depth: 0→ 201 +Lock-Token。占位期间他人访问 423;持有者随后PUT(或MKCOL)落地为真实文件,锁保留;直接UNLOCK则占位消失、不产生文件。 - 冲突:他人已锁 → 423(正文是 multistatus,
Lock-Token头是冲突锁的 token)。 - If 头是简化评估:提取
<...>内全部 token,任一匹配即视为持有者;不支持 RFC 的Not子句和 etag 条件。
解锁:
curl -s -o /dev/null -w "%{http_code}\n" -X UNLOCK \
-H "Lock-Token: <opaquelocktoken:...>" $B/dav/doc.md| 状态码 | 触发 |
|---|---|
| 204 | 解锁成功 |
| 400 | 缺 Lock-Token 头 |
| 409 | token 未知/已过期 |
PROPPATCH — 死属性
<D:propertyupdate xmlns:D="DAV:">
<D:set><D:prop><Z:tags xmlns:Z="urn:myapp">a,b,c</Z:tags></D:prop></D:set>
<D:remove><D:prop><Z:tags xmlns:Z="urn:myapp"/></D:prop></D:remove>
</D:propertyupdate>- 207 返回,每个属性独立 200(存/删成功)/403(活属性,拒绝)/404(删除不存在的属性)。
- 死属性持久化在 root 的
.bridge4.props.json(对 client 不可见)。 - 命名空间简化:属性按 local name 存取,
Z:tags与Y:tags视为同名。client 如需多命名空间隔离,可在属性名里自带前缀(如myapp_tags)。
TUI(Ink)
╭──────────────────────────────────────────────────────────────╮
│ Bridge4 v0.1.0 READ-ONLY│
│ Root D:\work\project │
│ WebDAV http://127.0.0.1:17894/dav/ │
│ Query http://127.0.0.1:17894/api/query │
│ Escal http://127.0.0.1:17894/api/escalate │
╰──────────────────────────────────────────────────────────────╯
21:04:11 GET /api/status → 200 6ms
21:04:12 PROPFIND /dav/ → 207 4ms
[q]uit [c]lear log [r]oot dir- 顶部状态区:模式徽标(RW 时红色警示条 +
[x]revoke提示)、root、三个入口 URL。 - 请求日志:最近 10 条(
RequestLog环形缓冲 200 条),方法/路径/状态码着色。 - 按键:
q退出 ·c清日志 ·r输入新 root(回车即时切换服务根,PropsStore/LockManager 随 root 重建,端口不变)·y/n响应升级 ·x撤销 RW。 - 非 TTY 环境(CI/管道):按键自动禁用(
useInput isActive守卫),界面照常渲染。
代码结构(给其他 agent 的导航)
src/
├── cli.tsx # 入口:argv 解析、装配 escalator/log/context/server/controller、渲染 Ink
├── context.ts # BridgeContext(root/port/mode/...)、createContext、assertWritable(403 + hint)
├── escalate.ts # Escalator:mode、pending、requestAccess/approve/deny/revoke、事件流
├── log.ts # RequestLog:EventEmitter + 环形缓冲
├── http-server.ts # createBridgeServer:CORS、路由分发、错误兜底、请求日志、context 热替换
├── sqlite/api.ts # executeQuery(纯函数,可单测)+ handleQuery(HTTP 包装)
├── webdav/
│ ├── paths.ts # resolveDavPath(防穿越)、HttpError、XML 转义、隐藏元文件常量
│ ├── xml.ts # PROPFIND/PROPPATCH/LOCK 请求体解析、If 头 token 提取(简化评估)
│ ├── props.ts # PropsStore:死属性 JSON 持久化(copy/purge 支持 COPY/MOVE/DELETE)
│ ├── locks.ts # LockManager:冲突检测/子树锁/lock-null/过期/迁移
│ ├── methods.ts # 11 个 DAV 方法处理器 + Range/Content-Type 映射
│ └── index.ts # dispatchWebdav:方法 → 处理器路由
└── tui/App.tsx # Ink 组件:状态区/日志/升级提示/root 输入框/按键分发关键扩展点:
- 新增 API:在
http-server.ts路由中加分支,处理器抛HttpError即自动错误兜底;需要写操作时先assertWritable(ctx)。 - 换 root:
BridgeServer.setContext(createContext({...}))——HTTP server 不重启,端口不变;PropsStore/LockManager随 context 重建,Escalator/RequestLog是跨 context 共享的长寿命对象。 - 调整只读策略:
context.ts的assertWritable与sqlite/api.ts的readonly计算是唯一两处开关。
已知简化点
If头只评估 token 列表(无 Not/etag 子句)。- 死属性按 local name 存储,命名空间会碰撞。
- PROPFIND 不支持 Depth infinity(403 拒绝,RFC 4918 允许)。
- COPY/MOVE 忽略 Depth 头(COPY 始终整树,MOVE 始终 infinity)。
- GET 只支持单段 Range,多段 Range 回退 200 全量。
- 锁存在内存里,重启即失效(刻意设计);LOCK 响应固定回
Timeout: Second-3600。 - WebDAV 写操作无回收站/确认,删除即真删(刻意设计)。
