199 lines
14 KiB
Markdown
199 lines
14 KiB
Markdown
# 前端架构
|
||
|
||
`web/` 的工程文档。视觉与交互见 [ui-design.md](ui-design.md),协议见 [protocol.md](protocol.md),接口见 [api.md](api.md)。
|
||
|
||
## 1. 技术栈
|
||
|
||
| 项 | 选择 | 说明 |
|
||
|---|---|---|
|
||
| 构建 | Vite | 纯客户端 SPA,不做 SSR |
|
||
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
|
||
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
|
||
| 状态 | Zustand | 按领域拆 store |
|
||
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、账号资源工作副本、草稿预览所需的本机文件 |
|
||
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
|
||
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
|
||
| 测试 | Vitest(单元)、Playwright(端到端) | |
|
||
| 二维码生成 | 自写编码器 `lib/qr.ts`(字节模式、纠错 M、版本 1–10,最多 213 字节),与 Python `qrcode` 逐模块对比验证,见 `lib/qr.test.ts` |
|
||
| 扫码 | 浏览器自带 `BarcodeDetector` + `getUserMedia`,零依赖;不支持时退回粘贴链接 |
|
||
| 加解密 | `@webdrop/core`,经 `core-bridge` 调用,在 Worker 中运行 | |
|
||
|
||
**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。
|
||
|
||
生产构建将 `web/package.json` 的版本、前后端各自的 commit/tag 和 UTC 构建时间编译进前端,首页右下角将时间换算为 Asia/Shanghai 并格式化为 `yyyy-MM-dd HH:mm`。部署构建通过 `WEBDROP_FRONTEND_COMMIT`、`WEBDROP_FRONTEND_TAG`、`WEBDROP_BACKEND_COMMIT`、`WEBDROP_BACKEND_TAG`、`WEBDROP_BUILD_TIME` 提供元数据;本地构建没有 CI 环境时尝试读取 Git,时间取本次构建时间。
|
||
|
||
## 2. 目录与分层
|
||
|
||
```
|
||
web/src/
|
||
├─ app/ 入口、Provider、导航、主题
|
||
├─ styles/ tokens.css(唯一的颜色来源)、base.css
|
||
├─ ui/ 无业务的基础组件、图标(Button、Sheet、Menu、Toast)
|
||
├─ features/ 按功能垂直切分:home / channel / join / item / settings
|
||
├─ store/ 领域状态与事件流 reducer(纯逻辑)
|
||
├─ core-bridge/ 对 @webdrop/core、Worker、网络传输的封装
|
||
└─ lib/ 与框架无关的小工具(链接解析、退避、格式化)
|
||
```
|
||
|
||
依赖方向**单向向下**:
|
||
|
||
```
|
||
app → features → ui
|
||
│
|
||
└──→ store → core-bridge → @webdrop/core
|
||
lib 可被任何层使用,lib 不依赖其他层
|
||
```
|
||
|
||
| 规则 | 说明 |
|
||
|---|---|
|
||
| `ui/` 不依赖 `features/`、`store/` | 基础组件无业务 |
|
||
| `features/` 之间不互相导入 | 需共享就上移到 `ui/` 或 `store/`;唯一例外:`channel/`、`home/` 是组合层,可导入 `item/`、`scan/`、`channel/` 里的弹层 |
|
||
| 组件不直接调用网络或加解密 | 只通过 `store/` 的 action 与 selector |
|
||
| 协议逻辑只在 `store/`、`core-bridge/`、`lib/` | 全部用纯函数,可脱离 DOM 测试 |
|
||
| 不使用 barrel(`index.ts` 汇总导出) | 直接从具体文件导入,利于 tree-shaking 与追踪 |
|
||
|
||
## 3. 状态模型
|
||
|
||
频道是追加日志,**条目状态由日志推导,不单独存储可变副本**。
|
||
|
||
| store | 持有 | 说明 |
|
||
|---|---|---|
|
||
| `channels` | 本机已保存的频道、当前会话的频道元信息 | 持久化到 IndexedDB |
|
||
| `items`(按频道) | 由帧装配出的条目 Map(`item_id → Item`) | 按 `item_id` 建 Map,不用数组 `find` |
|
||
| `transport` | 连接状态、`last_seq`、降级级别 | |
|
||
| `queue` | 待发送帧(已加密的字节) | 持久化到 IndexedDB |
|
||
|
||
条目用判别联合,所有 `switch` 必须穷尽:
|
||
|
||
```ts
|
||
type ItemState =
|
||
| { kind: 'receiving' }
|
||
| { kind: 'complete' }
|
||
| { kind: 'incomplete' }
|
||
| { kind: 'tampered' }
|
||
| { kind: 'removed'; reason: 'deleted' | 'expired' | 'evicted' | 'limit' };
|
||
```
|
||
|
||
组件订阅**派生出的最小值**(如 `isOnline`、某条目的 `state.kind`),不订阅整个 store。
|
||
|
||
## 4. 传输与重连
|
||
|
||
遵循 [api.md §降级与重连](api.md):WebSocket → SSE → 长轮询;心跳 20 秒,45 秒无消息判死;退避 1–30 秒加抖动;重连携带 `from=last_seq+1`;收到 `410` 则**从 `from=0` 全量同步**,并丢弃本地已不存在的条目(从 `oldest_seq` 读会漏掉 seq 更小的存活条目,见 api.md)。
|
||
|
||
- 传输层是 `core-bridge/` 内的状态机,对 `store/` 只暴露「记录流」和「连接状态」。
|
||
- 上传永远走 HTTP `POST`,依赖协议幂等重发,不需要前端去重逻辑之外的补偿。
|
||
- **队列里持久化的是已加密的帧**。重试必须发送完全相同的字节(protocol §5 的 IV 规则),前端任何位置都不得对同一 `(item_id, frame_index)` 重新加密。
|
||
|
||
## 5. 密钥与安全处理
|
||
|
||
| 规则 | 说明 |
|
||
|---|---|
|
||
| 取出 fragment 后立即 `history.replaceState` | 在任何异步代码之前完成 |
|
||
| K 导入为不可导出的 `CryptoKey` | 内存中不保留原始字节 |
|
||
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
|
||
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
|
||
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
|
||
| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;仅同步 link/ownerLink、草稿清单、邀请私钥和移除墓碑;不含终端 S、设备名、缓存或文件 Blob。按账号/访客分库,会话在 Worker 中使用,通过本机 IndexedDB 恢复 |
|
||
| thread 与虚拟文件夹 | `@webdrop/core` 用共享结构编码 `thread_id`/`folder_id` 和 CONTROL;mode 只切换是否加密;worker 解析控制正文,store 保留最新位置以防旧条目更新回退 |
|
||
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
|
||
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
|
||
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
|
||
|
||
## 6. 路由与导航
|
||
|
||
- 路由只有:`/`、`/c/:id`、`/c/:id/settings`。
|
||
- **所有跳转经 `app/navigation.ts` 的 `navigate(to, direction)`**,内部用 `startTransition` + `addTransitionType` 触发 view transition。禁止在组件中直接 `history.pushState` 或 `location.assign`。
|
||
- 浏览器后退/前进不会触发 view transition(`popstate` 同步),这是已知限制,接受其无动画。
|
||
- 页面级方向性 `ViewTransition` 写在**页面组件**里,不写在布局里;用一个共享的 `DirectionalTransition` 包装。
|
||
|
||
## 7. 动画实现要点
|
||
|
||
遵循 `react-view-transitions` 技能的约束,并与 [ui-design.md §9](ui-design.md) 一致:
|
||
|
||
- 动画 CSS 取自技能的配方(`--duration-*`、`fade`、`slide`、`nav-forward/back`),集中放在 `styles/transitions.css`,不在组件里写动画 CSS。
|
||
- `ViewTransition` 一律 `default="none"`,显式列出 `enter` / `exit`,成对出现。
|
||
- Suspense 的揭示动画用简单字符串属性,不用类型映射。
|
||
- 共享元素的 `name` 全局唯一(如 `item-${id}`),不在可同时挂载两处的复用组件里写具名 VT。
|
||
|
||
## 8. 性能约定
|
||
|
||
遵循 `react-best-practices` 技能中适用于纯客户端 SPA 的部分:
|
||
|
||
| 方面 | 约定 |
|
||
|---|---|
|
||
| 首屏体积 | 按路由 `lazy` 拆分;二维码、预览、设置等重组件动态加载;**禁止 barrel 导入** |
|
||
| 重渲染 | 订阅最小派生值;事件处理中才用的状态用 ref 或 `getState()`;高频值(上传进度)用 ref 或独立细粒度 store,不触发整列表重渲染 |
|
||
| 派生状态 | 渲染期计算,不用 effect 同步 |
|
||
| 列表 | `content-visibility: auto`;条目 key 为 `item_id` |
|
||
| 条件渲染 | 用三元,不用 `&&` 渲染可能为 `0` 的值 |
|
||
| 重计算 | 加解密、装配、哈希一律在 Worker,主线程不处理大文件 |
|
||
| 非紧急更新 | 用 `startTransition` / `useDeferredValue` |
|
||
| 模块级单例 | 只做一次的初始化(Worker、IndexedDB 连接)用惰性初始化,避免重复 |
|
||
|
||
`server-*` 与 Next.js 相关规则不适用本项目。
|
||
|
||
## 9. 可维护性约定
|
||
|
||
1. 一个文件一个职责;组件文件约 150 行为上限,超出就拆。
|
||
2. 不预先抽象。同样的代码第二次出现才抽,第三次必须抽。
|
||
3. 导出面最小化:不被其他文件使用的符号不导出。
|
||
4. 注释只写「为什么」,尤其是协议约束与安全理由,并指向文档章节(如 `// protocol §5: 重试必须发相同字节`)。
|
||
5. 颜色、间距、圆角、阴影都不得写死在组件里,一律走 `tokens.css`。
|
||
6. 改动协议相关行为前,先改 `docs/`,再改代码(与 architecture §7 一致)。
|
||
7. 每个 `store/` 与 `lib/` 的模块有对应的 Vitest;每个用户可见流程有 Playwright 用例。
|
||
|
||
## 10. 测试
|
||
|
||
| 层 | 工具 | 内容 |
|
||
|---|---|---|
|
||
| `lib/`、`store/` | Vitest | 条目装配、状态推导、退避、队列、链接解析(含降级防御的负例) |
|
||
| `core-bridge/` | Vitest | 传输降级、`410` 重同步、断线续传(用假传输) |
|
||
| 界面流程 | Playwright | 双浏览器内容一致;断网恢复后无丢失无重复;抓包无明文和 K |
|
||
| 视觉 | Playwright 截图 | 亮暗两套、手机与桌面宽度、reduced-motion、无 `backdrop-filter` 回退 |
|
||
|
||
## 11. 阶段(对应 architecture §7)
|
||
|
||
| 阶段 | 前端内容 |
|
||
|---|---|
|
||
| 3 | 设计系统与骨架(静态,假数据) → 创建、加入、文本与 JSON 实时同步、重连、发送队列、「我的频道」 |
|
||
| 4 | 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放 |
|
||
| 6 | PWA、Web Share Target、二维码与短配对码 |
|
||
|
||
在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。
|
||
|
||
## 12. 实际边界与会话
|
||
|
||
core-bridge/instance.ts 使用真实 Worker 实现,mock 仅用于原型和测试。账号与设备由 worker/account.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;频道成员/设置/设备席位通过 channelResources.ts 调用 API;thread/folder/file.published 共用 channelThreads.ts 的加密 CONTROL 日志。
|
||
|
||
@webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
|
||
|
||
频道和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步个人资料、邀请私钥、资源凭据和移除墓碑;设备 S、文件缓存与 outbox 保留本机。
|
||
|
||
## 13. 账号会话恢复与频道密钥领取
|
||
|
||
本机 IndexedDB 保存账号 session token、不可导出的保险库 CryptoKey 和最近验证的加密保险库,不保存密码或登录 verifier。启动时先恢复账号并验证服务端 session,再读取该账号的资源数据库;网络离线保留本机身份,服务端明确拒绝 session 时清除登录凭据。退出登录清除本机自动恢复记录。记住登录允许使用本浏览器的人访问账号资源。
|
||
|
||
登录/同步后按账号领取 /accounts/channels 密钥包,解密并核对频道 ID、模式和角色,再补齐本机频道;本机主动移除的资源墓碑不会被旧密钥包重新加入。新设备不复用设备 S。打开无 fragment 的频道地址时,已登录账号可以领取授权包,待接受邀请需用户确认加入。未授权时显示需要频道主邀请,不再只要求含密钥链接。
|
||
|
||
## 14. 用户概念映射与分组
|
||
|
||
界面 Space 对应既有顶层 ChannelInfo 与 /c/:id;界面频道对应 ThreadInfo 和 thread_id。ChannelGroupInfo 属于当前空间,ThreadInfo.groupId 可选。store 从 group-created/group-renamed/thread-grouped 事件重建分组;threadGroups 保留最新归属,避免延迟的创建事件将频道移回旧分组。分组不是文件夹,也不是成员/密钥边界。
|
||
|
||
## 15. 文件交互边界
|
||
|
||
FileExplorer 编排选择、菜单、拖放和目录导航,ExplorerFileRow 只订阅单个文件。选择范围与拖放封装在 lib/explorer.ts 中做纯逻辑校验;拖放数据仅含随机文件 ID 和当前空间/频道标识,不包含链接、密钥或文件内容。移动、删除、下载均经上层回调和 store 完成。
|
||
|
||
ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关闭。悬停预览由独立组件挂载,受限文件只渲染元数据。文件详情的显式解密流程继续沿用原有认证和下载计数。
|
||
|
||
## 16. 文件聊天消息与身份快照
|
||
|
||
ItemBase.author 为可选发送者快照 {username,displayName,avatar?}。文件的快照放入已有 file.published 加密目录记录,文本通过 item.author CONTROL 记录关联。store 的 authors 索引支持身份记录先于条目到达,并在晚到的下载/进度更新中保留身份,避免取文件内容后丢失头像。该快照用于展示,不代表逐账号数字签名。
|
||
|
||
ChatPane 订阅当前频道的条目 ID,ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
|
||
|
||
## 17. 本机凭据保存
|
||
|
||
accountPersistence 新增按 API endpoint 隔离的 credentials store。用户名和密码使用随机 IV 的 AES-GCM 密文保存;不可导出的 CryptoKey 通过 IndexedDB structured clone 保存在本机。只有登录表单读取时才在 Worker 解密并填充,不写入 localStorage、URL 或日志,不上传原始密码。加密存储不能防止已被控制的同源前端或终端。
|
||
|
||
AccountSession.remember 控制会话落盘,所有同步、资料更新与登录保存路径统一检查该标记。关闭记住或清除本机保存后,不能被轮询重新保存。正常退出保留已保存填充信息,明确清除或服务端拒绝 session 时删除。旧版保存的会话视为开启自动恢复,但没有原始密码,不伪造密码填充。
|