外观
单体仓架构
回答的问题:为什么四个站在同一个仓、同一份依赖里;边界靠什么维持;改一处会波及什么。
1. 形状
natlan-web/
├── package.json 唯一的包清单:一套依赖、一份 node_modules
├── ui/ 共享组件库(普通目录,非依赖包)
├── apps/
│ ├── control/ 控制台 console.natlan.io
│ ├── peering/ 对等门户 peering.natlan.io
│ ├── auth/ OAuth 授权界面 auth.natlan.io
│ └── home/ 品牌首页 + fleet 岛 natlan.io
├── scripts/ 仓级工具(i18n 完整性检查、Cap 重新 vendor)
└── docs/ 本文档体系apps/* 目录下没有 package.json。SvelteKit 不需要它,配置内联在各自的 vite.config.ts 里;构建脚本因此是 cd apps/X && vite build 的形式。
2. 依赖方向
只有一个方向是允许的:
apps/control ─┐
apps/peering ─┼─→ ui/ → node_modules
apps/auth ─┤
apps/home ─┘ui/ 不得反向依赖任何 app:库里不能出现 $lib/...、$app/...。组件需要宿主 能力时走可选 props 或 setUiHost() 注入,而不是 import。
单包结构没有任何机制强制这条边界(多包结构可以靠 exports 挡住),因此它是约定, 靠 review 与 npm run check 时的解析失败兜底。约定的完整表述见 reference/ui/contracts.md。
3. 为什么合仓
合仓前是四个独立仓,共享代码靠手工同步:
- 首页的 fleet 地图是控制台
FleetMap.svelte的同级目录相对引用,另配四个 shim 桩掉$lib/api、toast、urlstate、$app/environment,再写 CSS 把用不到的按钮藏起来; - 授权界面与控制台各存一份逐字节相同的 vendored Cap 组件,改一处要同步两处;
- 门户维护着控制台设计令牌的一份 trimmed 拷贝,缺了整套
--c-data-*与状态色阶, 库组件搬过去会渲染成无色。
合仓把这三笔账一次结清:地图、验证码、设计令牌各只剩一份。
4. 为什么不用 npm workspaces
试过并拆掉了。workspaces 的核心价值是独立版本化与独立发布,这里一个都用不上—— 四个站从同一个仓、同一次构建出去,ui/ 永远不会发到 registry。剩下的全是代价:
- 四份
package.json的 svelte / vite / bits-ui 版本要手动对齐,否则装出两份实例; - 要维护
exports子路径映射; - bits-ui 的 context(如
Tooltip.Provider)跨副本失效,得靠dedupe: ['svelte', 'bits-ui']补洞——首页的 vite 配置里原本就有这一行, 单包结构下这个洞不存在,那行随之删除。
换来的是没有强制边界(见第 2 节)。这是明知的取舍。
5. 四站的技术形态并不统一,这是有意的
| 站 | 框架 | 路由 | 产物 | 托管 |
|---|---|---|---|---|
| control | SvelteKit + adapter-static | 客户端路由,ssr=false | build/ | Cloudflare Worker(薄壳,盖 CSP nonce) |
| peering | SvelteKit + adapter-static | 客户端路由,ssr=false | build/ | Cloudflare Worker(纯静态资产) |
| auth | 朴素 Vite + Svelte | 单入口 /authorize,无路由 | dist/ | 源站 nginx 同源静态 |
| home | 手写 HTML + Worker 脚本 | 无 | 仓内文件直接打包 | Cloudflare Worker(自带 fetch 处理) |
差异的理由逐条落在各自的参考文档里:
- 授权页不用 SvelteKit:只有一个入口、没有路由也没有 SSR,套 SvelteKit 只会多一层 adapter 和
.svelte-kit产物。$ui别名因此在它的vite.config.ts里自行声明 (另外两站由sveltekit({ alias })注入)。 - 授权页不上 Worker:见 auth-hosting.md。
- 控制台需要 Worker:见 reference/csp.md。
6. 改一处会波及什么
| 改动 | 自动重建的站 | 需要手工介入的 |
|---|---|---|
apps/control/* | control | — |
apps/peering/* | peering | — |
apps/home/* | home | 若首页快照过期,另需 node bake.js |
apps/auth/* | — | 全部手工:构建 + 投放到源站 |
ui/* | control、peering、home | 授权页要手工重建投放 |
自动重建由 Cloudflare Workers Builds 的 Build watch paths 驱动(每个项目配 apps/<自己>/* + ui/*)。授权页不在 CF 上,因此不受 watch paths 覆盖——这是它 最容易被遗忘的一点,参见 guides/deploy-auth.md。
7. 共享的边界画在哪里
ui/ 收的是展示层:由 data prop 驱动、自己不取数、不认识 API、不做轮询。 因此同一个看板既能挂在控制台,也能挂在只读的门户上。
写操作一律做成可选 prop,不传即不渲染。例如 FleetMap 的 onRequestSnapshot: 控制台传真函数,门户和首页不传,按钮根本不进产物——不是渲染出来再拒绝。这是同一个 组件能在一处可写、在另外两处只读的全部机制,也是首页产物体积下降 47% 的来源。
取数、轮询、鉴权、URL 状态、toast 全部留在 app 层。各 app 的这层实现约定见 interaction-contracts.md。