阅读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是两个 Adaptermem/、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 个点
src和dist是两份代码:dist是编译产物,读src。- TS 里 import 写
.js后缀(如import './config.js'),但源文件是.ts。这是 NodeNext 模块解析的要求,不是写错了。 interface运行时不存在:没有反射、没有 Bean Validation。字段校验都是手写的(如server/index.ts:58)。- 没有 DI 容器:模块级
let cfg/Map就是全局单例(registry.ts)。初始化顺序很重要:必须先initRegistry(cfg),peer API 才有数据。 - 没有事务:
rebuildExport()先rm -rf再复制,中间存在导出目录为空的窗口。 - 异步错误经常被吞掉:
syncAllFriends().catch(() => {})、syncFriend里只记录lastError不抛出到 UI。看代码时要注意“失败后谁负责”。 - 两个 npm 工程:根目录是后端/CLI,
web/是独立前端,各有package.json和node_modules。 - 没有测试(这个仓库确实没有任何
*.test.*)。行为以代码和运行时为准,读的时候最好配合断点/日志。 - 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. 读代码时顺手能发现的问题(证明你读进去了)
src/server/index.ts:171返回version: '0.2.0',但package.json是0.3.0。src/config.ts:28的DB_PATH定义了但全项目没用到(旧设计残留)。unshare只从cfg.sharedDirs删除,registry.manifests不清理;而/api/peer/manifest/:dirId(index.ts:177)只查getManifest,所以重启前旧目录的清单仍可被好友读到(文件下载会被getSharedDir挡住)。friends.ts:111直接把对方 manifest 里的f.path拼到本地路径,没有像fileInManifest那样的..校验。对“完全可信的好友”没问题,但这是信任边界上的一个隐患。- 整个仓库没有测试,也没有后端 lint;只有前端有
oxlint。
评论 (0)
暂无评论。