前端不是一堆松散的 package。它围绕深模块组织:少数几个 package 拥有一大片复杂度,却只暴露很窄的公开 API。apps 与页面只从 barrel 导入,因此最难的部分——request pipeline、auth session、复合 UI glue——都各只有一处归属。
1. 这里的”深模块”是什么意思
深模块用很小的接口隐藏很宽的实现。在本仓里这是一条明文选择:package barrel(src/index.ts)是唯一被允许的导入入口,且 index 只再导出一小段刻意收窄的列表。
| Package | 隐藏 | 暴露 |
|---|---|---|
@bitz/platform-sdk | request pipeline、auth session、ProblemDetails 解析、租户/权限上下文、OpenAPI 生成契约 | createPlatformClient、PlatformProvider、usePlatform、PermissionGate、FeatureGate、createPlatformApi、config + error 辅助 |
@bitz/widgets | 分页、加载、空、错误、权限 glue;PlatformResult 映射 | ServerDataTable、AppShell、SidebarNav、HeaderBar、FormField、ConfirmAction、QueryState、EmptyState、ErrorState、PermissionGate |
@bitz/components | shadcn 原语与 cn 工具 | shadcn 原语 barrel(button、sidebar、table、dialog 等数十个再导出) |
@bitz/editor | Tiptap 配置 | 基于 StarterKit 的 useBitzEditor 封装 |
@bitz/i18n | React Context + 类型化 t() | 可替换的 i18n seam(I18nProvider、useI18n、useT、tRaw、zhCN) |
一个从 @bitz/widgets 导入 ServerDataTable 的页面,并不知道这张表是用 React Query 取数、如何把 ProblemDetails 错误映射成重试态、删除列又被哪个 gate 隐藏。这正是目的。
2. 依赖方向
箭头方向是固定的:apps 依赖 packages,packages 只依赖更底层的 package,且谁都不能深入 package 的内部路径。@bitz/platform-sdk 是唯一与后端通信的 package;@bitz/widgets 是唯一把数据获取与 UI 状态组合在一起的 package(它同时依赖 SDK 与 i18n,前一张图漏画了)。
3. Web 应用的请求路径
整个应用只有恰好一个 request pipeline 实例,在 PlatformProvider 里 useMemo 创建,经 React context(usePlatform().client)共享。各页面从不自建 client,也从不重写重试、刷新协调、超时、ProblemDetails 映射——这些全在唯一那条 pipeline 里。字段级细节见 平台契约层。
4. auth-session 状态机
AuthSession 是凭据状态与刷新协调的唯一归属。它的状态转移是架构契约,不是实现细节:
承重的不变量是单例刷新:并发 401 共享同一个在飞的 refreshOnce() promise。如果重试请求仍然是 401,会话调 forceRefreshFailed() 并发 refresh-failed 事件——这防止刷新风暴。监听器彼此隔离(各自 try/catch),所以一个抛错的监听器不会拖垮整个会话。
5. 红线
这些不可商量。每一条都因为失败后果是安全或正确性回归,而不是风格问题,并且已经落地。
| # | 红线 | 状态 | 原因 |
|---|---|---|---|
| 1 | 生成的 client 不手改 | 当前 | generated.d.ts 镜像后端 OpenAPI artifact;手改会 drift 并被下一次生成覆盖。 |
| 2 | 前端权限判断只算 UX | 当前 | 后端授权管道才是权限的唯一事实。 |
| 3 | refresh token 不进 localStorage | 当前 | refresh 只走 HttpOnly cookie;access token 是模块私有字段,任何脚本都偷不走。 |
| 4 | 权限事实不进 localStorage | 当前 | TenantId / UserId / 权限来自 /api/auth/me 或 JWT claim,不能自报。 |
| 5 | 禁手写后端 DTO | 当前 | src/contracts/ 只能给 generated schema 建命名桥接,不得复制字段。 |
| 6 | 页面禁散落 fetch | 当前 | 所有 HTTP 走 usePlatform().api。 |
| 7 | 单一 request pipeline 实例 | 当前 | PlatformProvider memo 一个 client;不得每页建 HTTP adapter。 |
6. 这种结构值得的地方
深模块、单 pipeline 的前期成本是纪律:你不能在页面里随手 fetch、不能抄 DTO、不能读 refresh token。回报是系统的硬不变量靠构造就成立:
- 安全不变量集中一处。 token 存储、刷新协调、ProblemDetails 处理全在
@bitz/platform-sdk。审这一个 package 就等于审整个 Web 应用的 auth 姿态。 - 后端变更靠重新生成传播。 后端 DTO 一变,重新生成 client 就把静默的运行时错配变成编译错误。
- 复合 UI 状态一致。 每个数据驱动的屏幕共享同样的 loading/empty/error/权限 形状,因为它们来自
@bitz/widgets,而不是每页自己拼 JSX。
7. 审一次架构改动
- 说出改动所在的 package,并确认它是 barrel 导入,不是深路径导入。
- 若引入新的 HTTP 调用,确认它走
usePlatform().api,不是页面级fetch。 - 若引入新的权限判断,确认后端同样强制了该能力。
- 若后端契约变化,重新导出 OpenAPI 并生成 client;
src/contracts/只调整 schema 别名。 - 提交前跑 workspace 检查(
yarn lint && yarn typecheck && yarn build)——Husky 与 lint-staged 会对暂存文件强制格式化与 lint。
8. 源核对
# 确认 barrel 是导入入口,apps/app 没有消费深路径。rg -n "from '@bitz/platform-sdk'|from '@bitz/widgets'" frontend/apps/app/srcrg -n "from '@bitz/widgets/src/|from '@bitz/platform-sdk/src/" frontend/apps/app/src# 预期:第一条命令返回多条匹配;第二条无匹配。
# 确认单一 request pipeline 与 auth session 的归属。rg -n "createPlatformClient|class AuthSession|refreshOnce|forceRefreshFailed" \ frontend/packages/platform-sdk/src/client frontend/packages/platform-sdk/src/auth