阅读nodejs项目

by Site Owner · 2026/10/28

1. 它为什么不是 MVC

一句话定位:

PSP 是一个“文件同步内核 + 两个入口”:CLI 和 HTTP 服务只是外壳,真正的业务在 src/mem 和 src/sync 里。

它按 能力/领域 切目录,而不是按 controller/service/repository 切层:

                        ┌──────────── 入口/适配器 ────────────┐
 终端命令  ──▶  src/cli.ts ─┐
                             ├──▶  src/mem/*    共享目录、清单、变更、导出
 浏览器 ──▶ web/dist ──▶ src/server/index.ts ─┤
                             ├──▶  src/sync/*   好友镜像拉取
 好友节点 ──HTTP──▶ /api/peer/* ───────────────┘
                             │
                             ▼
                    ~/.psp/  (文件系统就是数据库)

这其实更接近 六边形架构(端口-适配器):

  • cli.ts 和 server/index.ts 是两个 Adapter
  • mem/、sync/ 是 Core
  • 数据出口是 ~/.psp 下的 JSON 和目录

所以不要找 @RestController、@Service、@Repository,这里对应的是:

Java / Spring 世界 这个项目
main() + 命令分发 src/cli.ts:207 的 switch (cmd)
@RestController / Router src/server/index.ts:18 的 createApp(),路由是 lambda
@Service src/mem/*.ts、src/sync/friends.ts 导出的函数
@Repository / JPA 直接 fs.readFileSync/writeFileSync + JSON 文件
application.yml ~/.psp/config.json + src/config.ts
@Scheduled setInterval(watcher.ts:8、cli.ts 的 60s 同步)
Spring DI 容器 ESM import 本身:模块只会执行一次,导出的就是单例
DTO / VO interface,编译后类型全部擦除,运行时没有校验
Maven / Gradle package.json + package-lock.json + node_modules
内嵌 Tomcat @hono/node-server 的 serve()
Thymeleaf / JSP React SPA(web/),由后端静态托管
CompletableFuture Promise / async-await
ApplicationContext 启动 initRegistry() → createApp() → serve()

关键认知:这里没有数据库、没有 ORM、没有事务、没有 DI 容器。 “数据”就是 ~/.psp 下的文件;“依赖注入”就是 import;“事务”要靠代码自己保证(很多地方其实是直接覆盖写)。


2. 业务内核:先记住 5 个概念

概念 代码 含义
SharedDir config.ts 我要共享出去的目录:{id, path, name},id 是路径 sha256 前 8 位
Manifest manifest.ts:10 目录的“文件清单”:每个文件的 path/sha256/size/mtime + 整个目录的指纹 hash
FriendProfile friends.ts:10 好友名片:地址、配对码、对方目录摘要、selected、paused
Mirror(镜像) friends.ts:90 好友目录在本地的只读副本:~/.psp/friends/<昵称>/<dirId>/
Export(导出) exporter.ts:58 合并后的知识库:~/.psp/export/<昵称>/<目录名>/... + INDEX.md

整个项目的业务逻辑可以压缩成一句话:

把自己的共享目录做成带哈希的文件清单;好友之间通过 HTTP 比对清单,只拉取变化的文件,形成本地镜像;最后把“选中”的好友镜像合并到一个导出目录,再软链接给其它 Agent 使用。

注意:同步是“拉”不是“推”。每个节点每 60 秒主动去拉自己的好友,没有中心服务器,也没有推送。


3. 四条主流程(按执行顺序读)

流程 A:启动 psp start

代码:src/cli.ts:44

读参数 --port/--host
→ 检查 ~/.psp/config.json 是否存在
→ ensureDirs()
→ initRegistry(cfg)          # 为所有共享目录建 manifest(mem/registry.ts:12)
→ startWatcher(3000)         # 每 3 秒检测本地共享目录变化(watcher.ts:8)
→ createApp(cfg)             # 组装 Hono 路由(server/index.ts:18)
→ serve(127.0.0.1 或 0.0.0.0:7788)
→ 立即 syncAndRebuild(),之后每 60 秒一次(拉好友 + 重建导出)

流程 B:共享目录 + 变更感知

代码:src/mem/sources.ts、src/mem/registry.ts、src/mem/watcher.ts

psp share <路径> 或 POST /api/sources/share
→ shareDir(): 校验路径存在、去重、生成 id、写入 config.json(sources.ts:39)
→ registry.refreshChanged(): 重新 buildManifest
→ 每 3 秒 watcher 再刷新一次,hash 变了就打印日志
→ 好友下次来拉时,才能看到新内容

buildManifest 是核心算法(manifest.ts:35):

  • 递归遍历目录,忽略 .git、node_modules 等
  • 每个文件算 sha256,但 mtime+size 没变就复用上次的 hash,避免重复读文件
  • 把 path:sha256 排序后再 hash,得到目录指纹

流程 C:加好友 + 同步(最核心)

代码:src/sync/friends.ts:90-186

我(A)                                    好友(B)
addFriend()
  → GET /api/peer/hello ─────────────────▶ 校验 x-psp-code 配对码
  ◀──────────────────────────── dirs:[{dirId,name,fileCount,hash}](不含绝对路径)
  → 保存 profile.json
syncFriend()
  → 再次 hello,拿到对方目录列表
  → 对每个目录 syncOneDir():
       本地 .psp-manifest.json.hash == 对方 hash ?
         是 → 跳过
         否 → GET /api/peer/manifest/:dirId  拿完整清单
              → 只拉 sha256 不同的文件
              → GET /api/peer/file?dirId=&path=
              → 写入 ~/.psp/friends/<昵称>/<dirId>/
              → 删除对方已不存在的文件
              → 写 .psp-manifest.json
  → 对方取消共享的目录,本地镜像一并删除
  → 更新 profile.json
  → rebuildExport()

对应服务端:src/server/index.ts:160-199。这里有两个安全点值得注意:

  • peer.use('*') 校验 x-psp-code(index.ts:160)
  • /file 只允许 manifest 里存在的相对路径(fileInManifest,manifest.ts:86)

流程 D:导出 + 挂载

代码:src/mem/exporter.ts:58

selected 的好友镜像
→ rm -rf ~/.psp/export(全量重建)
→ 按 好友/目录/文件 复制
→ 对 md/txt 提取首个标题或首行,生成 INDEX.md
→ linkExport(): 在目标目录创建软链接 psp-kb -> ~/.psp/export(exporter.ts:116)
   - 已有自己的链接:幂等
   - 同名但不是自己的:拒绝覆盖
→ 其它 Agent 读 .claude/memory/psp-kb 就等于读合并知识库

4. 代码地图(读的时候按这个顺序打开)

文件 读它的目的
package.json、tsconfig.json、bin/psp.js 搞清怎么构建、入口在哪
src/cli.ts 所有命令和启动逻辑(先看 :207 的 switch)
src/config.ts 数据模型和持久化位置(:25 的 ~/.psp)
src/mem/manifest.ts 最核心的算法,不到 90 行
src/mem/registry.ts 进程内的 manifest 缓存
src/mem/sources.ts 目录探测/共享
src/mem/watcher.ts 3 秒轮询
src/sync/friends.ts HTTP 拉取 + 镜像(核心业务)
src/server/index.ts 本地 REST + peer API + 静态托管
src/mem/exporter.ts 合并导出 + 软链接
web/src/api.ts 前后端契约,先看这个
web/src/App.tsx 4 个页签的入口
web/src/pages/*.tsx 每个页面的交互

⚠️ 不要先读 dist/。那是 tsc 生成的 JS,和 src/ 一一对应。你如果打开的是 dist,会觉得更模糊。永远从 src/ 读。


5. 建议的阅读顺序(4 轮,每轮带问题)

第 1 轮:入口与命令(约 20 分钟)

读 package.json、bin/psp.js、cli.ts:44-84、cli.ts:207-260。

要能回答:

  • 有哪几种入口?(npm run dev、npm start、全局 psp)
  • start 做了哪 6 件事?
  • 哪些命令改 config.json,哪些改好友 profile.json?

第 2 轮:核心数据结构(约 40 分钟)

读 config.ts、manifest.ts 全文、registry.ts、sources.ts、watcher.ts。

要能回答:

  • 目录指纹 hash 是怎么算出来的?
  • 为什么 mtime+size 不变就能跳过读文件?
  • redis 在这里对应什么?(没有,是进程内 Map)
  • 共享目录被删除/取消共享后,manifest 会怎样?

第 3 轮:网络与同步(约 40 分钟)

读 server/index.ts:24-199、friends.ts:59-186。

要能回答:

  • 本地 API 和 /api/peer/* 的区别是什么?
  • 同步是 push 还是 pull?谁触发?
  • “只拉差异”具体比对的是什么?
  • 配对码起什么作用?没有它会怎样?

第 4 轮:导出与前端(约 30 分钟)

读 exporter.ts:58-135、web/src/api.ts:80-170、App.tsx、pages/Sources.tsx。

要能回答:

  • 导出目录结构是什么样?
  • 软链接是怎么安全创建的?什么情况下拒绝?
  • 前端一个按钮点击后,请求经过哪些文件?

最后做一次“业务追踪”:假设我要让好友 B 的文件出现在 ~/.claude/memory/psp-kb,从点击“添加好友”到文件可读,代码依次经过哪些函数?把这条链路在纸上画出来,你就真正读懂了。


6. Java 开发者最容易卡住的 9 个点

  1. src 和 dist 是两份代码:dist 是编译产物,读 src。
  2. TS 里 import 写 .js 后缀(如 import './config.js'),但源文件是 .ts。这是 NodeNext 模块解析的要求,不是写错了。
  3. interface 运行时不存在:没有反射、没有 Bean Validation。字段校验都是手写的(如 server/index.ts:58)。
  4. 没有 DI 容器:模块级 let cfg / Map 就是全局单例(registry.ts)。初始化顺序很重要:必须先 initRegistry(cfg),peer API 才有数据。
  5. 没有事务:rebuildExport() 先 rm -rf 再复制,中间存在导出目录为空的窗口。
  6. 异步错误经常被吞掉:syncAllFriends().catch(() => {})、syncFriend 里只记录 lastError 不抛出到 UI。看代码时要注意“失败后谁负责”。
  7. 两个 npm 工程:根目录是后端/CLI,web/ 是独立前端,各有 package.json 和 node_modules。
  8. 没有测试(这个仓库确实没有任何 *.test.*)。行为以代码和运行时为准,读的时候最好配合断点/日志。
  9. CLI 和 server 是两个进程:命令行 psp share 改的是磁盘上的 config.json;正在运行的 server 持有的是启动时加载进内存的 cfg,不会自动重读。所以服务运行中改共享目录,用网页操作,或者重启。这个点能解释很多“为什么我改了没生效”。

7. 把项目跑起来 + 用断点读

# 首次:初始化(会写 ~/.psp/config.json)
npx tsx src/cli.ts init

# 开发模式启动(tsx 直接跑 TS,不需要 build)
npm run dev

# 另开终端观察状态
curl -s localhost:7788/api/status
find ~/.psp -maxdepth 4 -type f
cat ~/.psp/config.json
cat ~/.psp/friends/*/profile.json

# 验证 peer 鉴权
curl -i localhost:7788/api/peer/hello                      # 401
curl -s -H "x-psp-code: $(python3 -c 'import json;print(json.load(open("'"$HOME"'/.psp/config.json"))["pairCode"])')" \
  localhost:7788/api/peer/hello

VS Code 调试配置(用 Java Debugger 的直觉来用):

{
  "type": "node",
  "request": "launch",
  "name": "PSP debug",
  "runtimeExecutable": "node",
  "runtimeArgs": ["--import", "tsx"],
  "program": "${workspaceFolder}/src/cli.ts",
  "args": ["start"],
  "cwd": "${workspaceFolder}",
  "console": "integratedTerminal",
  "skipFiles": ["<node_internals>/**"]
}

建议断点位置:

  • src/cli.ts:70(syncAndRebuild)
  • src/sync/friends.ts:111(写镜像文件)
  • src/mem/exporter.ts:72(复制到导出目录)
  • src/server/index.ts:182(peer 文件下载)

8. 开发逻辑:改一个功能要动哪些文件

需求 改动点
新增一个 REST API server/index.ts 加路由 → web/src/api.ts 加方法 → 对应页面调用
新增一个 CLI 命令 cli.ts 写 cmdX() → switch 加 case → help 文案
新增配置字段 config.ts 的 interface + defaultConfig()(旧配置靠 {...def, ...raw} 兜底,没有迁移框架)
改同步策略 friends.ts:90 syncOneDir()
改导出目录结构 exporter.ts:58 rebuildExport()
新增页面 web/src/pages/X.tsx → App.tsx 加 tab

这套代码的“开发逻辑”就是:先判断这个需求属于哪个 core 模块,再决定从 CLI 还是 HTTP 入口暴露,最后决定要不要同步改前端契约。


9. 读代码时顺手能发现的问题(证明你读进去了)

  1. src/server/index.ts:171 返回 version: '0.2.0',但 package.json 是 0.3.0。
  2. src/config.ts:28 的 DB_PATH 定义了但全项目没用到(旧设计残留)。
  3. unshare 只从 cfg.sharedDirs 删除,registry.manifests 不清理;而 /api/peer/manifest/:dirId(index.ts:177)只查 getManifest,所以重启前旧目录的清单仍可被好友读到(文件下载会被 getSharedDir 挡住)。
  4. friends.ts:111 直接把对方 manifest 里的 f.path 拼到本地路径,没有像 fileInManifest 那样的 .. 校验。对“完全可信的好友”没问题,但这是信任边界上的一个隐患。
  5. 整个仓库没有测试,也没有后端 lint;只有前端有 oxlint。

评论 (0)

暂无评论。


← back to home · edit