npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bridge4

v0.2.0

Published

Localhost bridge: serve a directory over WebDAV + SQLite query API with an Ink TUI

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,默认端口=17894
usage: 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 头,提示客户端如何升级。

安全模型(核心设计)

  1. 进程启动即为 read-only:文件系统写类 WebDAV 方法一律 403;sqlite 以 readonly + fileMustExist 物理只读打开。
  2. 客户端 POST /api/escalate 申请升级:请求挂起最多 60s;TUI 弹出提示,用户按 y/n。
  3. 通过后是全局模式切换(非按客户端 token):整个服务(WebDAV + sqlite)进入读写,直到:
    • TUI 按 x 主动撤销,或
    • 进程退出/重启(自动回落只读)。
  4. 无鉴权、无写保护规则——本机信任模型下这是刻意取舍。
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, UNLOCK

DAV: 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 无 body
HTTP/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.md
HTTP/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 写操作无回收站/确认,删除即真删(刻意设计)。