From 97c313df7c1f481b232a9d41348626f877900608 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 16 Jul 2026 12:16:08 +0800 Subject: [PATCH] refactor: extract shared relay service and enforce boundary --- .dockerignore | 14 + AGENTS-CN.md | 2 +- AGENTS.md | 2 +- Cargo.toml | 1 + .../agent-runtime-services-design.md | 5 +- .../platform-portability-design.md | 4 +- docs/architecture/product-architecture.md | 5 +- docs/plans/core-decomposition-completed.md | 2 + docs/plans/core-decomposition-plan.md | 17 +- .../product-architecture-evolution-plan.md | 23 +- scripts/check-core-boundaries.test.mjs | 300 ++++++++++++++++ .../cargo-dependency-boundaries.mjs | 331 +++++++++++++++++ scripts/core-boundaries/checker.mjs | 2 + .../core-boundaries/rules/crate-layout.mjs | 1 + scripts/core-boundaries/rules/crate-rules.mjs | 3 +- .../core-boundaries/rules/feature-rules.mjs | 2 +- scripts/core-boundaries/self-test.mjs | 8 +- src/apps/relay-server/Cargo.toml | 51 +-- src/apps/relay-server/Dockerfile | 45 +-- src/apps/relay-server/README.md | 40 ++- src/apps/relay-server/docker-compose.yml | 4 +- src/apps/relay-server/src/bin/relay_admin.rs | 13 +- src/apps/relay-server/src/lib.rs | 290 +-------------- src/apps/relay-server/src/main.rs | 12 +- src/apps/relay-server/tests/library_compat.rs | 37 ++ src/crates/assembly/AGENTS-CN.md | 3 +- src/crates/assembly/AGENTS.md | 6 +- src/crates/assembly/core/Cargo.toml | 6 +- .../service/remote_connect/embedded_relay.rs | 10 +- src/crates/services/AGENTS-CN.md | 1 + src/crates/services/AGENTS.md | 1 + src/crates/services/relay-service/AGENTS.md | 29 ++ src/crates/services/relay-service/Cargo.toml | 54 +++ .../services/relay-service}/src/admin.rs | 0 .../services/relay-service}/src/db.rs | 0 src/crates/services/relay-service/src/lib.rs | 337 ++++++++++++++++++ .../src/relay/device_manager.rs | 5 +- .../services/relay-service}/src/relay/mod.rs | 0 .../services/relay-service}/src/relay/room.rs | 0 .../services/relay-service}/src/routes/api.rs | 15 +- .../relay-service}/src/routes/auth.rs | 0 .../relay-service}/src/routes/devices.rs | 4 +- .../services/relay-service}/src/routes/mod.rs | 0 .../relay-service}/src/routes/sync.rs | 0 .../relay-service}/src/routes/websocket.rs | 0 45 files changed, 1275 insertions(+), 410 deletions(-) create mode 100644 .dockerignore create mode 100644 scripts/core-boundaries/cargo-dependency-boundaries.mjs create mode 100644 src/apps/relay-server/tests/library_compat.rs create mode 100644 src/crates/services/relay-service/AGENTS.md create mode 100644 src/crates/services/relay-service/Cargo.toml rename src/{apps/relay-server => crates/services/relay-service}/src/admin.rs (100%) rename src/{apps/relay-server => crates/services/relay-service}/src/db.rs (100%) create mode 100644 src/crates/services/relay-service/src/lib.rs rename src/{apps/relay-server => crates/services/relay-service}/src/relay/device_manager.rs (98%) rename src/{apps/relay-server => crates/services/relay-service}/src/relay/mod.rs (100%) rename src/{apps/relay-server => crates/services/relay-service}/src/relay/room.rs (100%) rename src/{apps/relay-server => crates/services/relay-service}/src/routes/api.rs (97%) rename src/{apps/relay-server => crates/services/relay-service}/src/routes/auth.rs (100%) rename src/{apps/relay-server => crates/services/relay-service}/src/routes/devices.rs (98%) rename src/{apps/relay-server => crates/services/relay-service}/src/routes/mod.rs (100%) rename src/{apps/relay-server => crates/services/relay-service}/src/routes/sync.rs (100%) rename src/{apps/relay-server => crates/services/relay-service}/src/routes/websocket.rs (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..3ef58448fd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +** +!src/ +!src/apps/ +!src/apps/relay-server/ +!src/apps/relay-server/Dockerfile +!src/apps/relay-server/Cargo.toml +!src/apps/relay-server/src/ +!src/apps/relay-server/src/** +!src/crates/ +!src/crates/services/ +!src/crates/services/relay-service/ +!src/crates/services/relay-service/Cargo.toml +!src/crates/services/relay-service/src/ +!src/crates/services/relay-service/src/** diff --git a/AGENTS-CN.md b/AGENTS-CN.md index a5ced96d5c..8ca0be4275 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -26,7 +26,7 @@ Stable Contracts and Security Control Plane 的边界以 | 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线和 adapter/service 注册 | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | 适配层 | `src/crates/adapters` | AI/API/transport/WebDriver/OpenCode 协议 adapter 和外部 provider 转换 | `ai-adapters`, `api-layer`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | -| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、harness、stream、DeepReview policy/report、plugin host 边界、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-host`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、LSP protocol/plugin DTO、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | diff --git a/AGENTS.md b/AGENTS.md index 0a204dac65..95508ad25e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Keep crate dependencies inside each layer to the smallest set needed. | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, and adapter/service registration | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | Adapters | `src/crates/adapters` | AI/API/transport/WebDriver/OpenCode protocol adapters and external-provider translation | `ai-adapters`, `api-layer`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | -| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `relay-service`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, plugin host boundary, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-host`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, LSP protocol/plugin DTOs, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | diff --git a/Cargo.toml b/Cargo.toml index 1deab41539..13adcdfc47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "src/crates/adapters/transport", "src/crates/services/services-core", "src/crates/services/services-integrations", + "src/crates/services/relay-service", "src/crates/services/terminal", "src/crates/assembly/product-capabilities", "src/crates/contracts/product-domains", diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 3901efcb96..a63388852a 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -714,7 +714,7 @@ ping 路由。未接入入口的 profile、枚举分支和单元测试仍不能 | 阶段 | 约束 | |---|---| | 当前 | CLI 与 Peer Host 消费真实 Runtime Parts 与 SDK,Core 兼容门面只承接 SDK v1 缺口;不扩张字段或再造描述符 | -| 迁移 | 迁移执行 owner 或继续接入 ACP、Desktop 前,必须分别证明行为等价;`assembly/core -> apps/relay-server` 反向依赖仍需消除 | +| 迁移 | 迁移执行 owner 或继续接入 ACP、Desktop 前,必须分别证明行为等价;relay 的 Cargo 反向边已删除,room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已下沉,但 embedded TCP bind、静态 fallback 和任务生命周期仍是 assembly 兼容债务 | | 完成 | 每个声称支持的 profile 都由生产入口消费组装结果,并有最小入口验证;无消费方的 profile 不对外宣称可用 | 产品定义、品牌资源和界面布局的长期边界以 @@ -1007,7 +1007,8 @@ Product 测试: 仍需完成: -- 消除 `assembly/core -> apps/relay-server` 的反向依赖,并用通用边界检查固定依赖方向。 +- 把 embedded relay 的 TCP bind、静态 fallback 和任务生命周期移出 assembly;room/device 状态、account/sync + 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,Cargo 反向边已删除并由通用边界检查保护。 - 继续缩小 CLI 的 Core 兼容门面;只有稳定端口、真实生产调用方和行为等价测试齐备时才迁移 owner。 - 让 ACP、Desktop 依次接入产品组装,并为每条路径证明行为等价;ACP 生命周期和 Desktop 平台资源仍留在入口。 - 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。 diff --git a/docs/architecture/platform-portability-design.md b/docs/architecture/platform-portability-design.md index 626dd603da..a3ab836722 100644 --- a/docs/architecture/platform-portability-design.md +++ b/docs/architecture/platform-portability-design.md @@ -65,13 +65,13 @@ Runtime。Rust target 可编译、`hdc shell` 能运行二进制和 HAP 内能 ## 4. 当前代码事实与风险 -基线为上游 `5e48999f94daf119c1217ed6b71ba878d564f5dd`。下表只记录已经从 manifest 或代码确认的事实; +本轮 Relay 边界核对基线为上游 `cabbce88348a24124714101a9e6c2f6371206fa1`(2026-07-16)。下表只记录已经从 manifest 或代码确认的事实; 目标依赖解析结果仍需由 target-specific `cargo tree` 证明。 | 已确认事实 | 风险或影响 | 最小处理 | |---|---|---| | CLI 直接启用 `bitfun-core/product-full` | 不能据此形成可裁剪的 HarmonyOS 产物 | 先建立目标依赖清单,再按真实阻塞项拆分 | -| `assembly/core` 依赖 `apps/relay-server` | 编译依赖方向反转 | 抽取可复用 relay owner,保持 standalone/embedded 行为等价 | +| `assembly/core -> apps/relay-server` 反向边已删除,room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`;embedded TCP bind、静态 fallback 与任务生命周期仍在 assembly | `product-full` 仍携带具体宿主生命周期,不能据此证明 HarmonyOS 本地产品可用 | 后续把 embedded 宿主逻辑移到具体宿主;本轮不扩张 HarmonyOS 接口 | | `assembly/core` 直接包含 bundled `rusqlite` | 交叉编译包含原生 C 风险 | 在本地会话样例需要时验证或替换存储实现 | | 非 Windows `git2` 使用 vendored OpenSSL | OHOS 原生构建和动态链接风险 | Git 只在编码就绪阶段取证,不阻塞最小界面 | | CLI 无条件依赖 `arboard` | 可能带入桌面 Linux/X11 依赖 | 剪贴板改为可选能力,不阻塞核心路径 | diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 9cdac3bd59..acfbf47dc3 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -270,8 +270,9 @@ flowchart LR - GUI/TUI 布局选择不复制主题 schema,不固化动态能力状态,也不携带可执行 UI 或任意构建脚本。 - 新 profile 只有在真实入口消费组装结果、能力可用性和类型化降级后才算接入;仅有枚举、空计划、re-export 或单测不构成产品支持。 -- assembly 不新增对 app crate 的依赖。现有嵌入式 relay 反向依赖必须通过抽取可复用 relay owner 消除,并由 - 边界检查阻止同类依赖回流。 +- assembly 不得依赖 app crate。relay 的 room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router + 归属 `services/relay-service`,Cargo metadata 实际解析图检查阻止同类依赖回流。embedded TCP bind、静态 fallback + 和任务生命周期暂留 assembly 兼容路径,仍需迁往具体宿主;这项边界修复不构成 HarmonyOS 本地产品支持。 - 平台支持按“目标依赖、编译、目标产品运行”三层取证。HarmonyOS 本地候选在 HAP 真机证明输入/绘制、路径、 网络、存储和进程行为前不能标记可用,也不能静默回退 Desktop/Remote 执行。 - 文档、边界脚本和 focused 测试能说明本次变更保护了哪个稳定接口切面,或删除/降级了哪个过宽接口。 diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 36ed7bf3b9..a1b47543a6 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -18,6 +18,7 @@ - 已抽取 `bitfun-core-types`、`bitfun-events`、`bitfun-runtime-ports`、`bitfun-agent-stream` 等基础契约;LSP protocol DTO 和 plugin manifest DTO 已进入 `bitfun-core-types`。 - 已建立 `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`tool-runtime`、`bitfun-tool-packs`、`bitfun-agent-runtime`、`bitfun-runtime-services`、`bitfun-harness`、`bitfun-product-domains`、`bitfun-product-capabilities` 等归属 crate。 - `src/crates` 已按 `interfaces / assembly / adapters / services / execution / contracts` 六层布局整理,DeepReview path classifier、边界规则、Cargo workspace 路径和根/层级 AGENTS 已同步。 +- Cargo metadata 实际解析图检查已覆盖 workspace 与独立 manifest 的 normal、build、dev 依赖及 optional/target 变体;未知 crate 层级与反向依赖会直接失败。 ## 2. 已迁移归属 @@ -28,6 +29,7 @@ - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection、AgenticEvent projection manifest、event version / aggregate / replay / retention facts、legacy WebSocket event allowlist 和 platform-neutral `EventEmitter` trait;Tauri/WebSocket transport 只负责 delivery。 - `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、remote chat image metadata / display helper projection、remote image lifecycle attachment mapping、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log 文件追加 / 脱敏 / HTTP dispatch、review-platform provider service / token store / HTTP transport / Git provider integration,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper、lifecycle status policy 和 MCP OAuth credential vault / store / authorization bootstrap;core 仍保留 debug-log HTTP ingest server、persisted turn adapter 和 MCP auth 的产品 data-dir 注入、授权入口、错误映射与 deprecated 兼容 wrapper。 +- `relay-service` 已承接 room/device 状态、account/sync 存储、asset store 和 HTTP/WebSocket router;standalone app 保留 bind、环境配置、静态 fallback、进程生命周期和管理 CLI,embedded 入口复用同一 router。embedded 的 bind、静态 fallback 和任务生命周期仍在 assembly 兼容路径,尚未完成宿主归位。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy、provider-entry materialization、materialized tool snapshot、provider identity、permission/effect filter、cancellation contract 和 stale-call guard;core 只保留 Computer Use 旧 public path re-export / compatibility shim、产品 Tool trait 适配与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy、WebFetch readable extraction / fallback / title / format facts、WebSearch Exa text result parsing,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用、Web tool network provider 调用和旧工具入口。 - `runtime-ports` / `terminal-core` / `services-integrations` 已承接 ExecCommand 会话执行端口和 concrete provider:`TerminalPort` 暴露本地命令执行、stdin 写入、会话控制和生命周期事件边界,`RemoteExecPort` 暴露远端 SSH 命令执行、bounded one-shot command、stdin、会话控制和生命周期事件边界;`TerminalRuntimePort` 复用原本地 `ExecProcessManager` 行为,`RemoteExecRuntimePort` 复用原 remote exec manager 与旧 SSH one-shot 行为,当前 desktop / CLI 产品入口和保留 server bootstrap 初始化路径通过 `CoreRuntimeServicesProvider` 构造 provider 并显式注入 `ConversationCoordinator` / 执行上下文 / `ToolRuntimeHandles`;core `ExecCommand` / `WriteStdin` / `ExecControl` 只消费端口,不再直接调用全局本地或远端进程 manager。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 93d2569602..bd570d33dc 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -27,7 +27,7 @@ | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | | Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v1 preview 门面和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用 envelope 或静态候选扩张稳定 ABI | -| Relay | `assembly/core` 直接依赖 `apps/relay-server` 以复用嵌入式 relay | 依赖方向反转,且当前边界检查未阻止该问题 | +| Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded 宿主逻辑仍在 assembly 兼容路径 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位是独立后续工作 | | CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;发布工作流负责打包 | 参数/序列化/前置失败和组装已有 focused contract;真实模型/PTY、Patch I/O 失败与常规 package smoke 仍需补齐 | ## 3. 目标依赖与归属 @@ -41,17 +41,18 @@ | contracts | 稳定 DTO、事实和端口 | 依赖上层或持有运行时行为 | 需要同时被独立应用和嵌入式模式复用的能力,先下沉为 services/adapters owner,再由 app 与 assembly 同向消费。 -Relay 是该规则的首个修复对象;不能把 `apps/relay-server` 改名后继续作为下层库。 +Relay 已按该规则完成首个修复;后续共享实现仍不得以 app crate 充当下层库。 ## 4. 迁移顺序 -### 4.1 先修边界保护 +### 4.1 已完成边界保护 -1. 抽取 relay router、room 与 asset-store 的可复用 owner。 -2. 让 standalone relay app 和嵌入式入口都依赖该 owner,删除 `assembly/core -> apps/relay-server`。 -3. 为 crate 层级依赖增加通用边界检查和反向用例,避免只保护已知 crate 名称。 +1. relay router、room、存储与 asset-store 已归属 `services/relay-service`。 +2. standalone relay app 和嵌入式入口共同依赖该 owner,`assembly/core -> apps/relay-server` 已删除。 +3. crate 层级依赖已增加 Cargo metadata 通用检查和反向用例,不再只保护已知 crate 名称。 -退出条件:生产行为与 standalone/embedded relay 测试等价,Cargo 图不再包含 assembly → apps。 +共享 owner 与 Cargo 方向的退出条件已满足:standalone 与 embedded 入口共用同一已测试 router,Cargo 图不再包含 +assembly → apps。embedded 的 bind、静态 fallback 和任务生命周期移出 assembly 是独立后续工作。 ### 4.2 切换 CLI 纵向路径 @@ -121,7 +122,7 @@ Core 只为插件兼容提供已有 owner 的窄接口:真实工具、类型 |---|---| | 文档与仓库边界 | `pnpm run check:repo-hygiene`,`node --test scripts/check-core-boundaries.test.mjs`,`node scripts/check-core-boundaries.mjs` | | 入口 profile 迁移 | 对应 app 的 check/test、入口级 smoke、profile/服务可用性断言、旧路径等价用例 | -| Relay owner 迁移 | standalone 与 embedded focused tests、Cargo 依赖方向失败用例 | +| Relay 共享 owner / Cargo 方向 | standalone 与 embedded focused tests、Cargo 依赖方向失败用例;embedded 宿主归位另行验证生命周期等价 | | Agent Runtime / SDK | `cargo test -p bitfun-agent-runtime`,最小 no-`bitfun-core` 嵌入测试 | | 插件首个执行切片 | runtime ports、Host、adapter、Tool Runtime 与真实冻结 fixture 的端到端调用 | | CLI | `cargo check -p bitfun-cli`,`cargo test -p bitfun-cli`,结构化协议和 package smoke | diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index d46cfbba6f..32c1c97cb4 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -6,8 +6,8 @@ [平台可行性](../architecture/platform-portability-design.md)和 [OpenCode 兼容](opencode-extension-compatibility-plan.md)。专项文档不能用自己的阶段编号扩大本计划范围。 -计划基线为上游 `5e48999f94daf119c1217ed6b71ba878d564f5dd`(2026-07-15)。当前 PR 只更新设计与计划, -不表示其中任何目标能力已经实现。 +本轮对照的上游基线为 `cabbce88348a24124714101a9e6c2f6371206fa1`(2026-07-16);本文所在提交记录 +本轮实现事实。后续事实变化必须随代码显式更新,只有代码、入口消费和对应验证同时成立的项目才标记为完成。 ## 1. 裁决原则 @@ -26,7 +26,7 @@ | 范围 | 当前事实 | 近期结论 | |---|---|---| -| 编译依赖 | `assembly/core` 依赖 `apps/relay-server`;现有检查没有通用覆盖全部 Cargo dependency kind | 先补通用检查并移除 relay 反向边 | +| 编译依赖 | `assembly/core -> apps/relay-server` 已移除;通用检查覆盖 normal/build/dev 依赖及 optional/target 变体 | 后续反向依赖和未知 crate 层级直接失败 | | 公开面 | `bitfun-core` 仍有迁移期 re-export;CLI 只完成部分 Runtime SDK 接入 | 按入口逐项迁移,不做全仓逐 symbol 台账或批量删除 | | CLI/TUI | `ShortcutsConfig` 已加载但真实按键分发仍硬编码;Slash、Palette、帮助和执行不是同一来源 | 先统一宿主 action 声明和键位解析,不重写 renderer | | OpenCode | 只有来源确认和静态工具名预览,没有 JS/TS `execute` 或真实工具注册 | 先做一个无外部依赖、遵循官方公开契约的 standalone custom tool 端到端样例 | @@ -37,10 +37,12 @@ 交付: -- 用 Cargo metadata 检查仓库层级方向,覆盖 workspace 和独立 manifest,以及 normal、build、dev、optional、 - target dependency。已知债务进入不增长基线,未知层级或新增反向依赖失败。 -- 把 relay runtime/router 等可复用逻辑移到 service/adapter owner,让 standalone relay app 和嵌入式入口共同消费; - TCP bind、静态资源服务和 app 生命周期留在具体入口。 +- Cargo metadata 实际解析图检查已覆盖 workspace、独立 manifest,以及 normal、build、dev 依赖及 optional/target 变体; + 未知层级或新增反向依赖直接失败。 +- relay 的 room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 + `services/relay-service`,standalone relay app 和嵌入式入口共同消费。 + standalone 的 TCP bind、静态 fallback 和进程生命周期留在 app;embedded 的对应宿主逻辑暂留 assembly 兼容路径, + 其迁移是独立后续工作,不构成 HarmonyOS 支持。 - 把 contracts/Product Domain 中的环境、路径或进程探测迁到 service。现有 Agent Runtime helper 只处理多生态 Skill 根,不是 custom tool resolver,应留在 Skill owner;`.opencode/tools/` 发现由 OpenCode adapter 新增并由 当前静态预览与后续执行共同消费。旧路径删除前保持生产行为等价。 @@ -49,8 +51,9 @@ 退出条件: -- `assembly -> apps` 反向边消失,standalone/embedded relay 行为测试通过; -- 边界检查能命中反向 fixture,并允许已登记债务只减不增; +- `assembly -> apps` 反向边消失,standalone/embedded relay 共用同一已测试 router;(共享 owner 与 Cargo 方向已满足, + embedded 宿主逻辑归位待后续) +- 边界检查能命中 normal/build/dev 依赖及 optional/target 反向 fixture;(已满足) - contracts 和 Agent Runtime 不再新增环境或生态来源探测; - 本工作流没有新增无调用方端口、空 registry 或第二个 Runtime owner。 @@ -116,7 +119,7 @@ Help/dispatch 元数据;终端异常路径仍能恢复。 | 工作 | 必须等待 | 可以并行 | |---|---|---| -| Relay owner 修复 | Cargo 边界检查基线 | OpenCode fixture、HarmonyOS 可行性样例 | +| Relay 共享 owner / 反向边修复 | 已完成;embedded 宿主归位待后续 | OpenCode fixture、HarmonyOS 可行性样例 | | CLI action/快捷键 | 当前 CLI 行为和配置 fixture | OpenCode standalone tool、入口 API 迁移 | | HarmonyOS 最小宿主 | HAP 可行性 go 决策、目标依赖清单 | Desktop OpenCode tool、CLI action | | HarmonyOS 本地核心 | 最小宿主、所需平台事实迁移 | Desktop OpenCode tool | diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index d46434f6f1..adfd8320f4 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1,11 +1,20 @@ import { access, readFile } from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { + collectCargoMetadataGraph, + collectCargoMetadataPackages, + findCargoLayerViolations, +} from './core-boundaries/cargo-dependency-boundaries.mjs'; +import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs'; + const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url); const MODULES = [ './core-boundaries/checker.mjs', + './core-boundaries/cargo-dependency-boundaries.mjs', './core-boundaries/manifest-feature-helpers.mjs', './core-boundaries/self-test.mjs', './core-boundaries/rules/crate-rules.mjs', @@ -17,6 +26,297 @@ const MODULES = [ './core-boundaries/rules/source/required-rules.mjs', ]; +const TEST_ROOT = join('C:', 'repo'); + +function packageAt(name, repoManifestPath, dependencies = []) { + return { + id: name, + name, + manifest_path: join(TEST_ROOT, ...repoManifestPath.split('/')), + dependencies, + }; +} + +function pathDependency(repoCratePath, options = {}) { + return { + name: options.name ?? repoCratePath.split('/').at(-1), + path: join(TEST_ROOT, ...repoCratePath.split('/')), + kind: options.kind ?? null, + optional: options.optional ?? false, + target: options.target ?? null, + }; +} + +test('cargo layer checker rejects reverse edges across dependency kinds', () => { + const packages = [ + packageAt('entry', 'src/apps/example/Cargo.toml'), + packageAt('adapter', 'src/crates/adapters/api-layer/Cargo.toml'), + packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [ + pathDependency('src/apps/example', { optional: true }), + ]), + packageAt('service', 'src/crates/services/services-core/Cargo.toml', [ + pathDependency('src/crates/adapters/api-layer'), + pathDependency('src/crates/assembly/core', { + kind: 'dev', + target: 'cfg(windows)', + }), + ]), + packageAt('runtime', 'src/crates/execution/agent-runtime/Cargo.toml', [ + pathDependency('src/crates/adapters/api-layer'), + pathDependency('src/crates/services/services-core'), + ]), + packageAt('contract', 'src/crates/contracts/core-types/Cargo.toml', [ + pathDependency('src/crates/services/services-core', { kind: 'build' }), + ]), + ]; + + const violations = findCargoLayerViolations(packages, { + root: TEST_ROOT, + crateLayoutRules, + }); + + assert.equal(violations.length, 6); + assert.match(violations[0].message, /assembly.*->.*entry.*apps.*normal optional dependency/); + assert.match(violations[1].message, /service.*services.*->.*adapter.*adapters.*normal dependency/); + assert.match(violations[2].message, /service.*services.*->.*assembly.*dev dependency.*cfg\(windows\)/); + assert.match(violations[3].message, /runtime.*execution.*->.*adapter.*adapters.*normal dependency/); + assert.match(violations[4].message, /runtime.*execution.*->.*service.*services.*normal dependency/); + assert.match(violations[5].message, /contract.*contracts.*->.*service.*services.*build dependency/); +}); + +test('cargo layer checker allows documented downward and peer dependencies', () => { + const packages = [ + packageAt('entry', 'src/apps/example/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp'), + pathDependency('src/crates/assembly/core'), + ]), + packageAt('interface', 'src/crates/interfaces/acp/Cargo.toml', [ + pathDependency('src/crates/assembly/core'), + ]), + packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [ + pathDependency('src/crates/services/services-core'), + pathDependency('src/crates/execution/agent-runtime'), + ]), + packageAt('service', 'src/crates/services/services-core/Cargo.toml', [ + pathDependency('src/crates/execution/agent-runtime'), + pathDependency('src/crates/contracts/core-types'), + ]), + packageAt('runtime', 'src/crates/execution/agent-runtime/Cargo.toml', [ + pathDependency('src/crates/contracts/core-types'), + ]), + packageAt('contract', 'src/crates/contracts/core-types/Cargo.toml'), + ]; + + assert.deepEqual( + findCargoLayerViolations(packages, { + root: TEST_ROOT, + crateLayoutRules, + }), + [], + ); +}); + +test('cargo layer checker uses resolved edges for locally patched dependencies', () => { + const entry = packageAt('entry', 'src/apps/example/Cargo.toml'); + const assembly = packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [ + { name: 'entry', path: null, kind: null, optional: false, target: null }, + ]); + + const violations = findCargoLayerViolations( + [entry, assembly], + { root: TEST_ROOT, crateLayoutRules }, + [{ + sourceManifestPath: assembly.manifest_path, + targetManifestPath: entry.manifest_path, + name: 'entry', + kind: null, + optional: false, + target: null, + }], + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /assembly.*->.*entry.*apps.*normal dependency/); +}); + +test('cargo layer checker combines declared path dependencies with resolved edges', () => { + const entry = packageAt('entry', 'src/apps/example/Cargo.toml'); + const assembly = packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [ + pathDependency('src/apps/example', { optional: true }), + ]); + + const violations = findCargoLayerViolations( + [entry, assembly], + { root: TEST_ROOT, crateLayoutRules }, + [], + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /assembly.*->.*entry.*apps.*normal optional dependency/); +}); + +test('cargo layer checker deduplicates renamed declared and resolved edges', () => { + const entry = packageAt('entry', 'src/apps/example/Cargo.toml'); + const assembly = packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [{ + ...pathDependency('src/apps/example', { name: 'entry', optional: true }), + rename: 'legacy_entry', + }]); + + const violations = findCargoLayerViolations( + [entry, assembly], + { root: TEST_ROOT, crateLayoutRules }, + [{ + sourceManifestPath: assembly.manifest_path, + targetManifestPath: entry.manifest_path, + name: 'legacy_entry', + kind: null, + optional: true, + target: null, + }], + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /assembly.*->.*entry.*apps.*normal optional dependency/); +}); + +test('cargo layer checker rejects repository packages without a known layer', () => { + const violations = findCargoLayerViolations( + [packageAt('mystery', 'tools/mystery/Cargo.toml')], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /unknown crate layer.*tools\/mystery\/Cargo\.toml/); +}); + +test('cargo metadata collection scans standalone manifests not covered by the workspace', () => { + const workspaceManifest = join(TEST_ROOT, 'Cargo.toml'); + const memberManifest = join(TEST_ROOT, 'src', 'apps', 'example', 'Cargo.toml'); + const installerManifest = join(TEST_ROOT, 'BitFun-Installer', 'src-tauri', 'Cargo.toml'); + const calls = []; + + const packages = collectCargoMetadataPackages({ + root: TEST_ROOT, + manifestPaths: [workspaceManifest, memberManifest, installerManifest], + loadMetadata(manifestPath) { + calls.push(manifestPath); + if (manifestPath === workspaceManifest) { + const entry = packageAt('entry', 'src/apps/example/Cargo.toml'); + return { packages: [entry], workspace_members: [entry.id] }; + } + if (manifestPath === installerManifest) { + return { packages: [packageAt('installer', 'BitFun-Installer/src-tauri/Cargo.toml')] }; + } + throw new Error(`workspace member metadata should not be loaded twice: ${manifestPath}`); + }, + }); + + assert.deepEqual(calls, [workspaceManifest, installerManifest]); + assert.deepEqual(packages.map((pkg) => pkg.name), ['entry', 'installer']); +}); + +test('cargo metadata collection rescans standalone packages discovered by the workspace', () => { + const workspaceManifest = join(TEST_ROOT, 'Cargo.toml'); + const serviceManifest = join(TEST_ROOT, 'src', 'crates', 'services', 'services-core', 'Cargo.toml'); + const assembly = packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [ + pathDependency('src/crates/services/services-core'), + ]); + const service = packageAt('service', 'src/crates/services/services-core/Cargo.toml', [ + pathDependency('src/apps/example', { optional: true }), + ]); + const entry = packageAt('example', 'src/apps/example/Cargo.toml'); + const calls = []; + + const graph = collectCargoMetadataGraph({ + root: TEST_ROOT, + manifestPaths: [workspaceManifest, serviceManifest], + loadMetadata(manifestPath) { + calls.push(manifestPath); + if (manifestPath === workspaceManifest) { + return { + packages: [assembly, service, entry], + workspace_members: [assembly.id], + resolve: { + nodes: [{ + id: assembly.id, + deps: [{ + name: 'service', + pkg: service.id, + dep_kinds: [{ kind: null, target: null }], + }], + }], + }, + }; + } + return { + packages: [service, entry], + workspace_members: [service.id], + resolve: { + nodes: [{ + id: service.id, + deps: [{ + name: 'example', + pkg: entry.id, + dep_kinds: [{ kind: null, target: null }], + }], + }], + }, + }; + }, + }); + + const violations = findCargoLayerViolations( + graph.packages, + { root: TEST_ROOT, crateLayoutRules }, + graph.resolvedDependencies, + ); + + assert.deepEqual(calls, [workspaceManifest, serviceManifest]); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /service.*services.*->.*example.*apps.*normal optional dependency/); +}); + +test('cargo metadata collection preserves resolved repository edges', () => { + const workspaceManifest = join(TEST_ROOT, 'Cargo.toml'); + const assembly = packageAt('assembly', 'src/crates/assembly/core/Cargo.toml', [{ + name: 'entry', + rename: null, + path: null, + kind: 'dev', + optional: true, + target: 'cfg(windows)', + }]); + const entry = packageAt('entry', 'src/apps/example/Cargo.toml'); + + const graph = collectCargoMetadataGraph({ + root: TEST_ROOT, + manifestPaths: [workspaceManifest], + loadMetadata() { + return { + packages: [assembly, entry], + resolve: { + nodes: [{ + id: assembly.id, + deps: [{ + name: 'entry', + pkg: entry.id, + dep_kinds: [{ kind: 'dev', target: 'cfg(windows)' }], + }], + }], + }, + }; + }, + }); + + assert.deepEqual(graph.packages.map((pkg) => pkg.name), ['assembly', 'entry']); + assert.equal(graph.resolvedDependencies.length, 1); + assert.equal(graph.resolvedDependencies[0].sourceManifestPath, assembly.manifest_path); + assert.equal(graph.resolvedDependencies[0].targetManifestPath, entry.manifest_path); + assert.equal(graph.resolvedDependencies[0].kind, 'dev'); + assert.equal(graph.resolvedDependencies[0].optional, true); + assert.equal(graph.resolvedDependencies[0].target, 'cfg(windows)'); +}); + test('core boundary check is split into focused modules', async () => { const entrypoint = await readFile(ENTRYPOINT, 'utf8'); assert.ok( diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs new file mode 100644 index 0000000000..494c8a7c68 --- /dev/null +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -0,0 +1,331 @@ +import { readdirSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const SKIPPED_DIRECTORIES = new Set([ + '.git', + '.targets', + '.worktrees', + 'node_modules', + 'target', +]); + +const ALLOWED_TARGET_LAYERS = new Map([ + ['apps', new Set(['interfaces', 'assembly', 'adapters', 'services', 'execution', 'contracts'])], + ['interfaces', new Set(['interfaces', 'assembly', 'adapters', 'services', 'execution', 'contracts'])], + ['assembly', new Set(['assembly', 'adapters', 'services', 'execution', 'contracts'])], + ['adapters', new Set(['adapters', 'services', 'execution', 'contracts'])], + ['services', new Set(['services', 'execution', 'contracts'])], + ['execution', new Set(['execution', 'contracts'])], + ['contracts', new Set(['contracts'])], +]); + +function normalizedPath(path) { + const normalized = resolve(path).replace(/\\/g, '/'); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function repositoryPath(root, path) { + const result = relative(resolve(root), resolve(path)).replace(/\\/g, '/'); + if (result === '' || result === '.') { + return ''; + } + if (result === '..' || result.startsWith('../') || isAbsolute(result)) { + return null; + } + return result; +} + +function layerForManifest(manifestPath, { root, crateLayoutRules }) { + const repoManifestPath = repositoryPath(root, manifestPath); + if (repoManifestPath === null) { + return null; + } + const cratePath = repoManifestPath.replace(/\/Cargo\.toml$/, ''); + + if (cratePath.startsWith('src/apps/') || cratePath === 'BitFun-Installer/src-tauri') { + return 'apps'; + } + + return crateLayoutRules.find((rule) => rule.path === cratePath)?.layer ?? null; +} + +function dependencyDescription(dependency) { + const kind = dependency.kind ?? 'normal'; + const optional = dependency.optional ? ' optional' : ''; + const target = dependency.target ? ` for ${dependency.target}` : ''; + return `${kind}${optional} dependency${target}`; +} + +export function findCargoLayerViolations( + packages, + { root, crateLayoutRules }, + resolvedDependencies = null, +) { + const packageByManifest = new Map( + packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), + ); + const layerByManifest = new Map(); + const violations = []; + + for (const pkg of packages) { + const layer = layerForManifest(pkg.manifest_path, { root, crateLayoutRules }); + layerByManifest.set(normalizedPath(pkg.manifest_path), layer); + if (!layer) { + const repoManifestPath = repositoryPath(root, pkg.manifest_path) ?? pkg.manifest_path; + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `unknown crate layer for repository package ${pkg.name} at ${repoManifestPath}`, + }); + } + } + + const declaredDependencies = []; + for (const sourcePackage of packages) { + for (const dependency of sourcePackage.dependencies ?? []) { + if (!dependency.path || repositoryPath(root, dependency.path) === null) { + continue; + } + + const targetManifestKey = normalizedPath(join(dependency.path, 'Cargo.toml')); + const targetPackage = packageByManifest.get(targetManifestKey); + if (!targetPackage) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `cargo metadata did not discover internal path dependency ${dependency.name} at ${repositoryPath(root, dependency.path)}`, + }); + continue; + } + + declaredDependencies.push({ + sourceManifestPath: sourcePackage.manifest_path, + targetManifestPath: targetPackage.manifest_path, + name: dependency.name, + kind: dependency.kind, + optional: dependency.optional, + target: dependency.target, + }); + } + } + + const dependenciesToCheck = new Map(); + for (const dependency of [ + ...declaredDependencies, + ...(resolvedDependencies ?? []), + ]) { + const key = [ + normalizedPath(dependency.sourceManifestPath), + normalizedPath(dependency.targetManifestPath), + dependency.kind ?? 'normal', + dependency.target ?? '', + ].join('|'); + const existing = dependenciesToCheck.get(key); + dependenciesToCheck.set(key, existing + ? { + ...existing, + optional: existing.optional && dependency.optional, + } + : dependency); + } + + for (const dependency of dependenciesToCheck.values()) { + const sourceManifestKey = normalizedPath(dependency.sourceManifestPath); + const targetManifestKey = normalizedPath(dependency.targetManifestPath); + const sourcePackage = packageByManifest.get(sourceManifestKey); + const targetPackage = packageByManifest.get(targetManifestKey); + if (!sourcePackage || !targetPackage) { + continue; + } + + const sourceLayer = layerByManifest.get(sourceManifestKey); + const targetLayer = layerByManifest.get(targetManifestKey); + if (!sourceLayer || !targetLayer || ALLOWED_TARGET_LAYERS.get(sourceLayer)?.has(targetLayer)) { + continue; + } + + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `cargo dependency layer violation: ${sourcePackage.name} (${sourceLayer}) -> ${targetPackage.name} (${targetLayer}) via ${dependencyDescription(dependency)}`, + }); + } + + return violations; +} + +export function discoverCargoManifestPaths(root) { + const manifests = []; + + function visit(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + visit(join(directory, entry.name)); + } + continue; + } + if (entry.isFile() && entry.name === 'Cargo.toml') { + manifests.push(join(directory, entry.name)); + } + } + } + + visit(root); + const workspaceManifest = normalizedPath(join(root, 'Cargo.toml')); + return manifests.sort((left, right) => { + if (normalizedPath(left) === workspaceManifest) { + return -1; + } + if (normalizedPath(right) === workspaceManifest) { + return 1; + } + return left.localeCompare(right); + }); +} + +function loadCargoMetadata(manifestPath, root) { + const result = spawnSync( + 'cargo', + ['metadata', '--format-version', '1', '--all-features', '--manifest-path', manifestPath], + { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }, + ); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || `exit code ${result.status}`).trim(); + throw new Error(`cargo metadata failed for ${manifestPath}: ${detail}`); + } + return JSON.parse(result.stdout); +} + +function resolvedDependencyRecords(metadata, root) { + const packageById = new Map((metadata.packages ?? []).map((pkg) => [pkg.id, pkg])); + const records = []; + + for (const node of metadata.resolve?.nodes ?? []) { + const sourcePackage = packageById.get(node.id); + if (!sourcePackage || repositoryPath(root, sourcePackage.manifest_path) === null) { + continue; + } + + for (const dependency of node.deps ?? []) { + const targetPackage = packageById.get(dependency.pkg); + if (!targetPackage || repositoryPath(root, targetPackage.manifest_path) === null) { + continue; + } + + const declarations = (sourcePackage.dependencies ?? []).filter((candidate) => + candidate.name === targetPackage.name + && (candidate.rename ?? candidate.name) === dependency.name + ); + const dependencyKinds = dependency.dep_kinds?.length > 0 + ? dependency.dep_kinds + : [{ kind: null, target: null }]; + + for (const dependencyKind of dependencyKinds) { + const kind = dependencyKind.kind ?? null; + const declaration = declarations.find((candidate) => + (candidate.kind ?? null) === kind + && (candidate.target ?? null) === (dependencyKind.target ?? null) + ) ?? declarations.find((candidate) => (candidate.kind ?? null) === kind) + ?? declarations[0]; + + records.push({ + sourceManifestPath: sourcePackage.manifest_path, + targetManifestPath: targetPackage.manifest_path, + name: dependency.name, + kind, + optional: declaration?.optional ?? false, + target: dependencyKind.target ?? null, + }); + } + } + } + + return records; +} + +export function collectCargoMetadataGraph({ + root, + manifestPaths = discoverCargoManifestPaths(root), + loadMetadata = (manifestPath) => loadCargoMetadata(manifestPath, root), +}) { + const packagesByManifest = new Map(); + const dependenciesByKey = new Map(); + const coveredManifests = new Set(); + const workspaceManifest = normalizedPath(join(root, 'Cargo.toml')); + const orderedManifests = [...manifestPaths].sort((left, right) => { + if (normalizedPath(left) === workspaceManifest) { + return -1; + } + if (normalizedPath(right) === workspaceManifest) { + return 1; + } + return left.localeCompare(right); + }); + + for (const manifestPath of orderedManifests) { + const manifestKey = normalizedPath(manifestPath); + if (manifestKey !== workspaceManifest && coveredManifests.has(manifestKey)) { + continue; + } + + const metadata = loadMetadata(manifestPath); + const workspaceMemberIds = new Set(metadata.workspace_members ?? []); + for (const pkg of metadata.packages ?? []) { + if (repositoryPath(root, pkg.manifest_path) === null) { + continue; + } + const packageManifestKey = normalizedPath(pkg.manifest_path); + if (workspaceMemberIds.has(pkg.id)) { + coveredManifests.add(packageManifestKey); + } + packagesByManifest.set(packageManifestKey, pkg); + } + for (const dependency of resolvedDependencyRecords(metadata, root)) { + const key = [ + normalizedPath(dependency.sourceManifestPath), + normalizedPath(dependency.targetManifestPath), + dependency.name, + dependency.kind ?? 'normal', + dependency.optional, + dependency.target ?? '', + ].join('|'); + dependenciesByKey.set(key, dependency); + } + } + + return { + packages: [...packagesByManifest.values()], + resolvedDependencies: [...dependenciesByKey.values()], + }; +} + +export function collectCargoMetadataPackages(options) { + return collectCargoMetadataGraph(options).packages; +} + +export function checkCargoDependencyLayers({ root, crateLayoutRules }) { + const { packages, resolvedDependencies } = collectCargoMetadataGraph({ root }); + return findCargoLayerViolations( + packages, + { root, crateLayoutRules }, + resolvedDependencies, + ); +} + +export function checkCargoDependencyLayersSafely({ root, crateLayoutRules }) { + try { + return checkCargoDependencyLayers({ root, crateLayoutRules }); + } catch (error) { + return [{ + path: join(root, 'Cargo.toml'), + line: 1, + message: `cargo dependency layer check failed to run: ${error.message}`, + }]; + } +} diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 3930562016..000b4e1de8 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -35,6 +35,7 @@ import { featureReferencesFeature, unexpectedDependencyOwnerFeatures, } from './manifest-feature-helpers.mjs'; +import { checkCargoDependencyLayersSafely } from './cargo-dependency-boundaries.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..', '..'); @@ -1125,6 +1126,7 @@ export function runCoreBoundaryCheck() { } checkCrateLayoutRules(); + failures.push(...checkCargoDependencyLayersSafely({ root: ROOT, crateLayoutRules })); for (const rule of forbiddenManifestDependencyRules) { checkForbiddenManifestDependencyRule(rule); diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 217fa5e688..5af769f3fc 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -20,6 +20,7 @@ export const crateLayoutRules = [ { crateName: 'services-core', layer: 'services', path: 'src/crates/services/services-core' }, { crateName: 'services-integrations', layer: 'services', path: 'src/crates/services/services-integrations' }, + { crateName: 'relay-service', layer: 'services', path: 'src/crates/services/relay-service' }, { crateName: 'terminal', layer: 'services', path: 'src/crates/services/terminal' }, { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 94e8a8dadd..dc95842a14 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -276,7 +276,7 @@ export const dependencyProfileRules = [ 'aes-gcm', 'bitfun-product-capabilities', 'bitfun-product-domains', - 'bitfun-relay-server', + 'bitfun-relay-service', 'bitfun-tool-packs', 'chrono-tz', 'cron', @@ -557,7 +557,6 @@ export const dependencyProfileRules = [ 'tokio-tungstenite', 'uuid', 'which', - 'bitfun-relay-server', ], }, ]; diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 2938d6f605..37424bd91c 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -11,7 +11,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime'] }, { depName: 'bitfun-product-capabilities', ownerFeatures: ['product-capabilities'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, - { depName: 'bitfun-relay-server', ownerFeatures: ['service-integrations'] }, + { depName: 'bitfun-relay-service', ownerFeatures: ['service-integrations'] }, { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, { depName: 'chrono-tz', ownerFeatures: ['product-full'] }, { depName: 'cron', ownerFeatures: ['product-full'] }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index f2214f4efb..2960b8fd96 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -601,7 +601,7 @@ export function runManifestParserSelfTest({ 'rmcp', 'image', 'tool-runtime', - 'bitfun-relay-server', + 'bitfun-relay-service', 'htmd', 'legible', 'readability-js', @@ -635,7 +635,7 @@ export function runManifestParserSelfTest({ throw new Error(`core optional dependency owner rule must cover forbidden dependency ${dep}`); } } - for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-service']) { if (!coreOptionalOwnerDeps.has(dep)) { throw new Error(`core optional dependency owner rule must cover ${dep}`); } @@ -652,11 +652,7 @@ export function runManifestParserSelfTest({ const servicesOptionalOwnerDeps = new Set( servicesOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [], ); - const servicesIntegrationsDefaultOnlyGuardDeps = new Set(['bitfun-relay-server']); for (const dep of servicesIntegrationsDefaultProfile?.forbiddenNonOptionalDeps ?? []) { - if (servicesIntegrationsDefaultOnlyGuardDeps.has(dep)) { - continue; - } if (!servicesOptionalOwnerDeps.has(dep)) { throw new Error( `services-integrations optional dependency owner rule must cover forbidden dependency ${dep}`, diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index 3189e10a0d..e8cad8785e 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -3,7 +3,7 @@ name = "bitfun-relay-server" version = "0.2.13" authors = ["BitFun Team"] edition = "2021" -description = "BitFun Relay Server - WebSocket relay for Remote Connect" +description = "BitFun standalone Remote Connect relay server" [lib] name = "bitfun_relay_server" @@ -18,56 +18,19 @@ name = "relay-admin" path = "src/bin/relay_admin.rs" [dependencies] -# NOTE: Dependencies are intentionally inlined rather than inherited from the -# workspace so that this crate can be built standalone inside its Docker -# context (src/apps/relay-server/) without copying the whole workspace. -# Web framework -axum = { version = "0.8", features = ["json", "ws"] } -tower-http = { version = "0.6.11", features = ["cors", "fs"] } - -# Async runtime +# Dependencies stay explicit so this application and relay-service can be +# built from their reduced standalone Docker context. +bitfun-relay-service = { path = "../../crates/services/relay-service" } +axum = "0.8" +tower-http = { version = "0.6.11", features = ["fs"] } tokio = { version = "1.52", features = ["full"] } -futures-util = "0.3.31" - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling anyhow = "1.0" - -# Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Utilities -uuid = { version = "1.0", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde", "clock"] } -dashmap = "6" -rand = "0.8" -base64 = "0.22" -sha2 = "0.10" - -# Account storage (SQLite). The relay stays zero-knowledge: it only stores -# encrypted blobs and password-derived hashes; it never holds a master key. -# libsqlite3-sys is pinned to 0.30 to match the workspace's rusqlite 0.32 and -# avoid duplicate native `sqlite3` links; `bundled` keeps the Docker build -# self-contained (no system libsqlite3 needed). -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros"] } -libsqlite3-sys = { version = "0.30", features = ["bundled"] } - -# Crypto — used only by the `relay-admin` provisioning tool to derive KEK / -# password hashes and wrap the master key *before* writing to the DB. -# The relay server runtime itself never touches these crates. -argon2 = "0.5" -aes-gcm = "0.10" - -# CLI parsing for the admin tool +chrono = { version = "0.4", features = ["clock"] } clap = { version = "4", features = ["derive", "env"] } rpassword = "7" -# Lints are inlined (not workspace=true) so the crate still builds inside the -# standalone Docker context under src/apps/relay-server/. [lints.rust] unsafe_op_in_unsafe_fn = "warn" unexpected_cfgs = "warn" diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index 235744ea6b..b0dfcbb5dc 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -1,15 +1,11 @@ -# BitFun Relay Server — standalone Docker build. -# Build context is relay-server root (Cargo.toml + src/), no workspace needed. -# -# Multi-arch: build natively on the deploy host (linux/amd64 or linux/arm64). -# Do not force --platform unless you intentionally cross-build with qemu. +# BitFun Relay Server standalone Docker build. +# The reduced repository-root context contains only the relay app and service. FROM rust:1-slim AS builder -WORKDIR /build +WORKDIR /build/src/apps/relay-server -# Optional: limit rustc parallelism on small / low-memory VPS (esp. arm64). -# docker compose build --build-arg CARGO_BUILD_JOBS=1 +# Optional: limit rustc parallelism on small or low-memory VPS hosts. ARG CARGO_BUILD_JOBS= ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} ENV DEBIAN_FRONTEND=noninteractive @@ -22,20 +18,29 @@ RUN apt-get update \ ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY Cargo.toml ./ -RUN mkdir -p src/bin \ - && echo 'fn main() { println!("placeholder"); }' > src/main.rs \ - && echo 'fn main() { println!("placeholder"); }' > src/bin/relay_admin.rs -# Dependency cache layer (placeholder sources). Ignore failure so a toolchain -# mismatch does not abort before real sources are copied. -RUN cargo build --release 2>/dev/null || true +COPY src/apps/relay-server/Cargo.toml ./Cargo.toml +COPY src/crates/services/relay-service/Cargo.toml ../../crates/services/relay-service/Cargo.toml -RUN rm -rf src target/release/bitfun-relay-server target/release/relay-admin target/release/deps/bitfun* +# Build placeholders first so unchanged dependencies remain cached. +RUN mkdir -p src/bin ../../crates/services/relay-service/src \ + && printf 'fn main() {}\n' > src/main.rs \ + && printf 'pub use bitfun_relay_service::*;\n' > src/lib.rs \ + && printf 'fn main() {}\n' > src/bin/relay_admin.rs \ + && printf '// placeholder\n' > ../../crates/services/relay-service/src/lib.rs \ + && cargo build --release -COPY src/ src/ +RUN rm -rf src ../../crates/services/relay-service/src \ + target/release/bitfun-relay-server \ + target/release/relay-admin \ + target/release/deps/bitfun_relay_service* \ + target/release/deps/bitfun_relay_server* \ + target/release/deps/relay_admin* + +COPY src/apps/relay-server/src/ ./src/ +COPY src/crates/services/relay-service/src/ ../../crates/services/relay-service/src/ RUN cargo build --release \ - && strip target/release/bitfun-relay-server target/release/relay-admin || true + && (strip target/release/bitfun-relay-server target/release/relay-admin || true) FROM debian:bookworm-slim @@ -46,8 +51,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=builder /build/target/release/bitfun-relay-server /app/bitfun-relay-server -COPY --from=builder /build/target/release/relay-admin /app/relay-admin +COPY --from=builder /build/src/apps/relay-server/target/release/bitfun-relay-server /app/bitfun-relay-server +COPY --from=builder /build/src/apps/relay-server/target/release/relay-admin /app/relay-admin RUN mkdir -p /app/static /app/data /app/room-web ENV RELAY_PORT=9700 diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index b7f0e1b145..f3f5e238ba 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -183,6 +183,24 @@ password. - Same machine Desktop + CLI share one `device_id`; the **last successful** `AuthConnect` wins as the live Peer Host for that id +## Upgrade notes + +The supported Docker build context is now the repository root because the app +uses the shared relay service: + +```bash +docker build -f src/apps/relay-server/Dockerfile . +docker compose -f src/apps/relay-server/docker-compose.yml build +``` + +Copying only `src/apps/relay-server` is no longer sufficient; deployments must +also include `src/crates/services/relay-service`. The repository keeps one +Docker build layout rather than duplicating the shared service. + +The Rust crate path `bitfun_relay_server` remains as a thin compatibility +facade, including its existing module paths and four-argument router builder. +New library consumers should depend on `bitfun-relay-service`. + ## Quick Start (service ops) ### Recommended: Run on the target server @@ -291,8 +309,9 @@ Used by Desktop / CLI / mobile-web for presence and Peer Device Mode RPC. #### Device RPC timeouts (Peer HostInvoke) `POST /api/devices/:target_device_id/rpc` waits up to **120 seconds** for the -target device (`RPC_TIMEOUT` in `src/routes/devices.rs`). Peer Device Mode uses -this for product `invoke` calls. +target device (`RPC_TIMEOUT` in +`../../crates/services/relay-service/src/routes/devices.rs`). Peer Device Mode +uses this for product `invoke` calls. Reverse proxies in front of the relay must use a read / response timeout **≥ 120s** (recommend 130s), or clients see **HTTP 504** before Axum finishes. @@ -340,8 +359,8 @@ Session sync posts a **full** encrypted session bundle. Large conversations can exceed Axum’s default ~2 MiB limit and fail with **HTTP 413**. This server raises the limit on sync POSTs to **64 MiB** (`SYNC_BODY_LIMIT` in -`src/routes/sync.rs`). Proxies must raise their body limit too, or they reject -uploads before Axum sees them: +`../../crates/services/relay-service/src/routes/sync.rs`). Proxies must raise +their body limit too, or they reject uploads before Axum sees them: ```nginx # nginx — must be >= Axum SYNC_BODY_LIMIT (64M) @@ -416,14 +435,9 @@ Mobile ──HTTP──► Relay ◄──WebSocket── Desktop / CLI relay-server/ ├── src/ │ ├── main.rs # Relay server binary entry point -│ ├── lib.rs # Shared library (router, asset stores) │ ├── config.rs # Environment-based configuration -│ ├── db.rs # SQLite account/device/sync storage -│ ├── admin.rs # Account provisioning crypto (used by relay-admin) -│ ├── bin/ -│ │ └── relay_admin.rs # relay-admin CLI binary -│ ├── relay/ # Room manager + device routing manager -│ └── routes/ # HTTP/WS route handlers (auth, devices, sync, api, websocket) +│ └── bin/ +│ └── relay_admin.rs # relay-admin CLI binary ├── static/ # Mobile-web static files ├── Cargo.toml ├── Dockerfile @@ -435,6 +449,10 @@ relay-server/ └── README.md ``` +Reusable relay state, storage, asset stores, and HTTP/WebSocket routes live in +`src/crates/services/relay-service`. This directory owns only the standalone +process configuration, static-file fallback, and operator CLI. + ## About `src/apps/server` vs `src/apps/relay-server` - Self-hosted Remote Connect **and** open-source account login use **this** diff --git a/src/apps/relay-server/docker-compose.yml b/src/apps/relay-server/docker-compose.yml index e7ad4222ac..baa7de873d 100644 --- a/src/apps/relay-server/docker-compose.yml +++ b/src/apps/relay-server/docker-compose.yml @@ -1,8 +1,8 @@ services: relay-server: build: - context: . - dockerfile: Dockerfile + context: ../../.. + dockerfile: src/apps/relay-server/Dockerfile args: # Pass through optional parallelism limit for low-memory hosts: # RELAY_CARGO_BUILD_JOBS=1 docker compose build diff --git a/src/apps/relay-server/src/bin/relay_admin.rs b/src/apps/relay-server/src/bin/relay_admin.rs index 032d99a410..78a94fc4cd 100644 --- a/src/apps/relay-server/src/bin/relay_admin.rs +++ b/src/apps/relay-server/src/bin/relay_admin.rs @@ -63,16 +63,17 @@ enum Command { async fn main() -> Result<()> { let cli = Cli::parse(); - let pool = bitfun_relay_server::db::connect(&cli.db).await?; + let pool = bitfun_relay_service::db::connect(&cli.db).await?; match cli.command { Command::AddUser { username, password } => { let password = resolve_password(password)?; - let user_id = bitfun_relay_server::admin::add_user(&pool, &username, &password).await?; + let user_id = + bitfun_relay_service::admin::add_user(&pool, &username, &password).await?; println!("Created account: username='{username}' user_id={user_id}"); } Command::ListUsers => { - let users = bitfun_relay_server::admin::list_users(&pool).await?; + let users = bitfun_relay_service::admin::list_users(&pool).await?; if users.is_empty() { println!("No accounts found."); } else { @@ -87,12 +88,12 @@ async fn main() -> Result<()> { } } Command::DeleteUser { username } => { - bitfun_relay_server::admin::delete_user(&pool, &username).await?; + bitfun_relay_service::admin::delete_user(&pool, &username).await?; println!("Deleted account: {username}"); } Command::ResetPassword { username, password } => { let password = resolve_password(password)?; - bitfun_relay_server::admin::reset_password(&pool, &username, &password).await?; + bitfun_relay_service::admin::reset_password(&pool, &username, &password).await?; println!("Password reset for: {username}"); println!("NOTE: All previously synced sessions/settings are now unreadable"); println!(" (they were encrypted with the old master key)."); @@ -101,7 +102,7 @@ async fn main() -> Result<()> { username, new_username, } => { - bitfun_relay_server::admin::rename_user(&pool, &username, &new_username).await?; + bitfun_relay_service::admin::rename_user(&pool, &username, &new_username).await?; println!("Renamed: {username} → {new_username}"); } } diff --git a/src/apps/relay-server/src/lib.rs b/src/apps/relay-server/src/lib.rs index 5862305705..70700a6c85 100644 --- a/src/apps/relay-server/src/lib.rs +++ b/src/apps/relay-server/src/lib.rs @@ -1,283 +1,25 @@ -//! BitFun Relay Server Library +//! Compatibility import path for relay library consumers. //! -//! Shared relay logic used by both the standalone relay-server binary and -//! the embedded relay running inside the desktop process. -//! -//! The relay is a stateless HTTP-to-WebSocket bridge: -//! - Desktop clients connect via WebSocket -//! - Mobile clients interact via HTTP POST -//! - The relay forwards encrypted payloads without inspection -//! - Per-room mobile-web static files are managed via `WebAssetStore` - -pub mod admin; -pub mod db; -pub mod relay; -pub mod routes; - -pub use relay::room::{ResponsePayload, RoomManager}; -pub use routes::api::AppState; - -use axum::extract::DefaultBodyLimit; -use axum::routing::{get, post}; -use axum::Router; -use dashmap::DashMap; -use std::collections::HashMap; -use std::sync::Arc; - -// ── WebAssetStore trait ─────────────────────────────────────────────── - -/// Abstract storage for per-room mobile-web static assets. -/// -/// The standalone relay uses `DiskAssetStore` (filesystem-backed), while -/// the embedded relay uses `MemoryAssetStore` (in-memory DashMap-backed). -pub trait WebAssetStore: Send + Sync + 'static { - /// Check if content with this SHA-256 hash exists in the store. - fn has_content(&self, hash: &str) -> bool; - - /// Store content by its SHA-256 hash. No-op if already present. - fn store_content(&self, hash: &str, data: Vec) -> Result<(), String>; - - /// Associate a relative file path within a room to a stored content hash. - fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String>; - - /// Retrieve file content for serving. Falls back to `index.html` if the - /// requested path doesn't exist (SPA routing). - fn get_file(&self, room_id: &str, path: &str) -> Option>; - - /// Check if any web files have been uploaded for this room. - fn has_room_files(&self, room_id: &str) -> bool; - - /// Remove all uploaded web files for a room. - fn cleanup_room(&self, room_id: &str); -} - -// ── MemoryAssetStore ────────────────────────────────────────────────── - -/// In-memory asset store backed by DashMap. Used by the embedded relay. -pub struct MemoryAssetStore { - content_store: DashMap>>, - room_manifests: DashMap>, -} - -impl MemoryAssetStore { - pub fn new() -> Self { - Self { - content_store: DashMap::new(), - room_manifests: DashMap::new(), - } - } -} - -impl Default for MemoryAssetStore { - fn default() -> Self { - Self::new() - } -} - -impl WebAssetStore for MemoryAssetStore { - fn has_content(&self, hash: &str) -> bool { - self.content_store.contains_key(hash) - } - - fn store_content(&self, hash: &str, data: Vec) -> Result<(), String> { - self.content_store - .entry(hash.to_string()) - .or_insert_with(|| Arc::new(data)); - Ok(()) - } - - fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String> { - self.room_manifests - .entry(room_id.to_string()) - .or_default() - .insert(rel_path.to_string(), hash.to_string()); - Ok(()) - } - - fn get_file(&self, room_id: &str, path: &str) -> Option> { - let manifest = self.room_manifests.get(room_id)?; - let hash = manifest.get(path).or_else(|| manifest.get("index.html"))?; - let content = self.content_store.get(hash)?; - Some(content.value().as_ref().clone()) - } +//! Runtime ownership lives in `bitfun-relay-service`. New code should depend +//! on that crate directly; this facade preserves the existing import paths. - fn has_room_files(&self, room_id: &str) -> bool { - self.room_manifests.contains_key(room_id) - } +pub use bitfun_relay_service::{ + admin, db, relay, routes, AppState, DiskAssetStore, MemoryAssetStore, ResponsePayload, + RoomManager, WebAssetStore, +}; - fn cleanup_room(&self, room_id: &str) { - self.room_manifests.remove(room_id); - } -} - -// ── DiskAssetStore ──────────────────────────────────────────────────── - -/// Filesystem-backed asset store. Used by the standalone relay server. -/// -/// Content is stored in `{base_dir}/_store/{hash}` and symlinked into -/// per-room directories `{base_dir}/{room_id}/{path}`. -pub struct DiskAssetStore { - base_dir: String, - known_hashes: DashMap, -} - -impl DiskAssetStore { - pub fn new(base_dir: &str) -> Self { - let store_dir = std::path::PathBuf::from(base_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let known: DashMap = DashMap::new(); - if store_dir.is_dir() { - if let Ok(entries) = std::fs::read_dir(&store_dir) { - for entry in entries.flatten() { - if let Ok(meta) = entry.metadata() { - if meta.is_file() { - if let Some(name) = entry.file_name().to_str() { - known.insert(name.to_string(), meta.len()); - } - } - } - } - } - } - tracing::info!( - "DiskAssetStore initialized with {} entries from {base_dir}", - known.len() - ); - Self { - base_dir: base_dir.to_string(), - known_hashes: known, - } - } - - fn store_dir(&self) -> std::path::PathBuf { - std::path::PathBuf::from(&self.base_dir).join("_store") - } - - fn room_dir(&self, room_id: &str) -> std::path::PathBuf { - std::path::PathBuf::from(&self.base_dir).join(room_id) - } -} - -impl WebAssetStore for DiskAssetStore { - fn has_content(&self, hash: &str) -> bool { - self.known_hashes.contains_key(hash) - } - - fn store_content(&self, hash: &str, data: Vec) -> Result<(), String> { - let store_path = self.store_dir().join(hash); - if !store_path.exists() { - std::fs::write(&store_path, &data).map_err(|e| e.to_string())?; - self.known_hashes - .insert(hash.to_string(), data.len() as u64); - } - Ok(()) - } - - fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String> { - let store_path = self.store_dir().join(hash); - let dest = self.room_dir(room_id).join(rel_path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - create_link(&store_path, &dest).map_err(|e| e.to_string()) - } - - fn get_file(&self, room_id: &str, path: &str) -> Option> { - let room_dir = self.room_dir(room_id); - let target = room_dir.join(path); - let file = if target.is_file() { - target - } else { - room_dir.join("index.html") - }; - if file.is_file() { - std::fs::read(&file).ok() - } else { - None - } - } - - fn has_room_files(&self, room_id: &str) -> bool { - self.room_dir(room_id).exists() - } - - fn cleanup_room(&self, room_id: &str) { - let dir = self.room_dir(room_id); - if dir.exists() { - if let Err(e) = std::fs::remove_dir_all(&dir) { - tracing::warn!("Failed to clean up room web dir {}: {e}", dir.display()); - } else { - tracing::info!("Cleaned up room web dir for {room_id}"); - } - } - } -} - -fn create_link(original: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(original, link) - } - #[cfg(not(unix))] - { - std::fs::hard_link(original, link).or_else(|_| std::fs::copy(original, link).map(|_| ())) - } -} - -// ── Router builder ──────────────────────────────────────────────────── - -/// Build the relay router with all API, WebSocket, and static-file routes. -/// -/// Both the standalone binary and the embedded relay call this function, -/// passing their own `WebAssetStore` implementation. +/// Builds the shared relay router using this compatibility host's version. pub fn build_relay_router( - room_manager: Arc, - asset_store: Arc, + room_manager: std::sync::Arc, + asset_store: std::sync::Arc, start_time: std::time::Instant, - db: Option>, -) -> Router { - let state = AppState { + db: Option>, +) -> axum::Router { + bitfun_relay_service::build_relay_router( room_manager, - start_time, asset_store, + start_time, db, - login_rate_limiter: std::sync::Arc::new(crate::routes::auth::LoginRateLimiter::new()), - device_manager: crate::relay::DeviceManager::new(), - }; - - Router::new() - .route("/health", get(routes::api::health_check)) - .route("/api/info", get(routes::api::server_info)) - .route( - "/api/auth/login/challenge", - post(routes::auth::login_challenge), - ) - .route("/api/auth/login", post(routes::auth::login)) - .route("/api/auth/logout", post(routes::auth::logout)) - .route("/api/auth/delegate", post(routes::auth::delegate)) - .route("/api/rooms/{room_id}/pair", post(routes::api::pair)) - .route( - "/api/rooms/{room_id}/command", - post(routes::api::command).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route( - "/api/rooms/{room_id}/upload-web", - post(routes::api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route( - "/api/rooms/{room_id}/check-web-files", - post(routes::api::check_web_files), - ) - .route( - "/api/rooms/{room_id}/upload-web-files", - post(routes::api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route("/r/{*rest}", get(routes::api::serve_room_web_catchall)) - .route("/ws", get(routes::websocket::websocket_handler)) - .merge(routes::sync::sync_router()) - .merge(routes::devices::device_router()) - .layer(tower_http::cors::CorsLayer::permissive()) - .with_state(state) + env!("CARGO_PKG_VERSION"), + ) } diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs index 0b1df48942..3ffcc3bdc3 100644 --- a/src/apps/relay-server/src/main.rs +++ b/src/apps/relay-server/src/main.rs @@ -8,7 +8,7 @@ use tracing::info; mod config; -use bitfun_relay_server::{build_relay_router, DiskAssetStore, RoomManager, WebAssetStore}; +use bitfun_relay_service::{build_relay_router, DiskAssetStore, RoomManager, WebAssetStore}; use config::RelayConfig; #[tokio::main] @@ -39,7 +39,7 @@ async fn main() -> anyhow::Result<()> { let start_time = std::time::Instant::now(); let db = if let Some(path) = &cfg.db_path { - match bitfun_relay_server::db::connect(path).await { + match bitfun_relay_service::db::connect(path).await { Ok(pool) => Some(Arc::new(pool)), Err(e) => { tracing::error!( @@ -53,7 +53,13 @@ async fn main() -> anyhow::Result<()> { None }; - let mut app = build_relay_router(room_manager, asset_store, start_time, db); + let mut app = build_relay_router( + room_manager, + asset_store, + start_time, + db, + env!("CARGO_PKG_VERSION"), + ); if let Some(static_dir) = &cfg.static_dir { info!("Serving static files from: {static_dir}"); diff --git a/src/apps/relay-server/tests/library_compat.rs b/src/apps/relay-server/tests/library_compat.rs new file mode 100644 index 0000000000..ca180f2bbb --- /dev/null +++ b/src/apps/relay-server/tests/library_compat.rs @@ -0,0 +1,37 @@ +use bitfun_relay_server::{ + admin, build_relay_router, db, relay, routes, AppState, DiskAssetStore, MemoryAssetStore, + ResponsePayload, RoomManager, WebAssetStore, +}; +use std::sync::Arc; +use std::time::Instant; + +#[test] +fn legacy_library_path_exposes_supported_relay_api() { + let _: fn( + Arc, + Arc, + Instant, + Option>, + ) -> axum::Router = build_relay_router; + let _ = admin::list_users; + let _ = db::connect; + let _ = DiskAssetStore::new; + let _ = MemoryAssetStore::new; + let _ = RoomManager::new; + let _ = relay::room::RoomManager::new; + let _ = routes::api::health_check; + let _ = routes::api::server_info(); + let _: Option = None; + let _ = std::mem::size_of::(); + let _ = AppState { + room_manager: RoomManager::new(), + start_time: Instant::now(), + asset_store: Arc::new(MemoryAssetStore::new()), + db: None, + login_rate_limiter: Arc::new(routes::auth::LoginRateLimiter::new()), + device_manager: relay::DeviceManager::new(), + }; + + fn require_store() {} + require_store::(); +} diff --git a/src/crates/assembly/AGENTS-CN.md b/src/crates/assembly/AGENTS-CN.md index a3a361e1cb..44c9de4d08 100644 --- a/src/crates/assembly/AGENTS-CN.md +++ b/src/crates/assembly/AGENTS-CN.md @@ -22,7 +22,8 @@ ## 依赖边界 - `assembly/core` 可以依赖下层 owner 来组装当前产品 runtime。 -- 组装 crate 不得依赖 `src/apps/*`。现有 embedded relay 反向依赖属于待迁移债务,不能作为新增 app 依赖的先例。 +- 组装 crate 不得依赖 `src/apps/*`。embedded relay 的 Cargo 反向依赖已经删除;其 TCP 绑定、静态资源 fallback + 和任务生命周期仍是 assembly 内的兼容路径,不得复制,也不能据此宣称宿主归属已经迁移完成。 - 组装层可以依赖 adapter 与 service crate,但不实现它们的协议序列化、认证、transport 或平台细节。 - 避免在组装层直接使用宿主 API;Tauri 支持必须保持 feature-gated,并尽可能由 app 或 adapter 拥有。 - interface crate 可以调用组装 API;adapter 和 service 不得依赖组装层。 diff --git a/src/crates/assembly/AGENTS.md b/src/crates/assembly/AGENTS.md index f2d067ac4b..b0d59a10c3 100644 --- a/src/crates/assembly/AGENTS.md +++ b/src/crates/assembly/AGENTS.md @@ -32,8 +32,10 @@ integration, or stable product-domain contracts. - `assembly/core` may depend on lower owner layers to assemble the current product runtime. -- Assembly crates must not depend on `src/apps/*`. The existing embedded-relay - reverse edge is migration debt, not a precedent for new app dependencies. +- Assembly crates must not depend on `src/apps/*`. The embedded-relay Cargo + reverse edge has been removed. Its TCP binding, static fallback, and task + lifecycle remain a compatibility path in assembly and must not be copied or + treated as evidence that host ownership has finished migrating. - Assembly may depend on adapter and service crates for selected delivery forms, but should not implement their protocol serialization, authentication, transport, or platform details. diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 772dd93bc3..a9309a753c 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -129,8 +129,8 @@ tokio-tungstenite = { workspace = true, optional = true } # SSH - Remote SSH support (optional feature) russh = { workspace = true, optional = true } -# Relay server shared library (embedded relay reuses standalone relay logic) -bitfun-relay-server = { path = "../../../apps/relay-server", optional = true } +# Relay runtime shared by embedded and standalone application hosts. +bitfun-relay-service = { path = "../../services/relay-service", optional = true } # Event layer dependency (lowest layer) bitfun-core-types = { path = "../../contracts/core-types" } @@ -213,7 +213,7 @@ runtime-services = [] service-integrations = [ "dep:aes-gcm", "dep:axum", - "dep:bitfun-relay-server", + "dep:bitfun-relay-service", "dep:git2", "dep:image", "dep:md5", diff --git a/src/crates/assembly/core/src/service/remote_connect/embedded_relay.rs b/src/crates/assembly/core/src/service/remote_connect/embedded_relay.rs index 075c732eaf..ca3ae7d14f 100644 --- a/src/crates/assembly/core/src/service/remote_connect/embedded_relay.rs +++ b/src/crates/assembly/core/src/service/remote_connect/embedded_relay.rs @@ -4,7 +4,7 @@ //! standalone relay-server binary. Uses `MemoryAssetStore` for in-memory //! mobile-web file storage (no disk I/O for uploaded assets). -use bitfun_relay_server::{build_relay_router, MemoryAssetStore, RoomManager}; +use bitfun_relay_service::{build_relay_router, MemoryAssetStore, RoomManager}; use log::info; use std::sync::Arc; @@ -28,7 +28,13 @@ pub async fn start_embedded_relay( } }); - let mut app = build_relay_router(room_manager, asset_store, start_time, None); + let mut app = build_relay_router( + room_manager, + asset_store, + start_time, + None, + env!("CARGO_PKG_VERSION"), + ); if let Some(dir) = static_dir { info!("Embedded relay: serving static files from {dir}"); diff --git a/src/crates/services/AGENTS-CN.md b/src/crates/services/AGENTS-CN.md index 50e5d282a6..710357ff0a 100644 --- a/src/crates/services/AGENTS-CN.md +++ b/src/crates/services/AGENTS-CN.md @@ -10,6 +10,7 @@ |---|---|---| | `services-core` | 不包含产品组装决策的本地 service primitive,包括 LSP plugin registry、session storage、metadata store CRUD/index rebuild、metadata 构造/计数/索引/字段 mutation、lineage 规则和 JSON file IO | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | MCP、git、remote、file watch、MiniApp runtime、产品领域 port 具体实现,以及平台无关的 Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | +| `relay-service` | standalone 与 embedded 宿主共享的 Remote Connect relay 状态、存储及 HTTP/WebSocket 路由 | [AGENTS.md](relay-service/AGENTS.md) | | `terminal` | PTY、shell integration 与 terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | ## 放置规则 diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index 57b9906dee..3e7747a711 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -13,6 +13,7 @@ OS/network capabilities. |---|---|---| | `services-core` | Reusable local service primitives, filesystem helpers, LSP plugin registry rules, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, review-platform provider service, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | +| `relay-service` | Reusable Remote Connect relay state, storage, and HTTP/WebSocket routes shared by standalone and embedded hosts | [AGENTS.md](relay-service/AGENTS.md) | | `terminal` | PTY, shell integration, and terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | ## Placement Rules diff --git a/src/crates/services/relay-service/AGENTS.md b/src/crates/services/relay-service/AGENTS.md new file mode 100644 index 0000000000..11e4072513 --- /dev/null +++ b/src/crates/services/relay-service/AGENTS.md @@ -0,0 +1,29 @@ +# Relay Service + +This crate owns the reusable Remote Connect relay runtime shared by standalone +and embedded hosts. + +## Ownership + +- Room and device state, account provisioning and sync storage, HTTP/WebSocket + routes, and memory/disk web asset stores belong here. +- Standalone host binding, environment configuration, static-file fallback, + process lifecycle, and administrative CLI parsing/output remain in the app. +- The existing embedded host still binds TCP, installs its static fallback, + and manages its task lifecycle in assembly as a compatibility path. That is + follow-up ownership debt, not part of this reusable service. +- Hosts supply the version reported by the shared health and info routes. +- Keep the relay runtime zero-knowledge: it persists encrypted payloads, + derived hashes, and wrapped keys. Operator provisioning may generate a master + key only to wrap it before storage; plaintext keys must not be retained. + +## Boundaries + +- Do not depend on assembly, interface, or application crates. +- Standalone and embedded hosts must construct the same router from this crate. +- Do not introduce host-specific APIs or duplicate the relay runtime per host. + +## Verification + +Run `cargo test -p bitfun-relay-service` and +`node scripts/check-core-boundaries.mjs` after changes. diff --git a/src/crates/services/relay-service/Cargo.toml b/src/crates/services/relay-service/Cargo.toml new file mode 100644 index 0000000000..8d774111fe --- /dev/null +++ b/src/crates/services/relay-service/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "bitfun-relay-service" +version = "0.2.13" +authors = ["BitFun Team"] +edition = "2021" +description = "Reusable relay runtime for BitFun Remote Connect" + +[lib] +name = "bitfun_relay_service" +path = "src/lib.rs" + +[dependencies] +# Dependencies stay explicit so the service can be built with the standalone +# relay application without copying the root workspace manifest into Docker. +axum = { version = "0.8", features = ["json", "ws"] } +tower-http = { version = "0.6.11", features = ["cors"] } +tokio = { version = "1.52", features = ["full"] } +futures-util = "0.3.31" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +tracing = "0.1" +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock"] } +dashmap = "6" +rand = "0.8" +base64 = "0.22" +sha2 = "0.10" + +# The relay stores encrypted blobs and password-derived hashes, not master keys. +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros"] } +libsqlite3-sys = { version = "0.30", features = ["bundled"] } +argon2 = "0.5" +aes-gcm = "0.10" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } + +[lints.rust] +unsafe_op_in_unsafe_fn = "warn" +unexpected_cfgs = "warn" +unreachable_pub = "warn" +unused_lifetimes = "warn" + +[lints.clippy] +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +perf = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +dbg_macro = "warn" +todo = "warn" +unimplemented = "warn" +undocumented_unsafe_blocks = "warn" diff --git a/src/apps/relay-server/src/admin.rs b/src/crates/services/relay-service/src/admin.rs similarity index 100% rename from src/apps/relay-server/src/admin.rs rename to src/crates/services/relay-service/src/admin.rs diff --git a/src/apps/relay-server/src/db.rs b/src/crates/services/relay-service/src/db.rs similarity index 100% rename from src/apps/relay-server/src/db.rs rename to src/crates/services/relay-service/src/db.rs diff --git a/src/crates/services/relay-service/src/lib.rs b/src/crates/services/relay-service/src/lib.rs new file mode 100644 index 0000000000..7ee8f6eeb9 --- /dev/null +++ b/src/crates/services/relay-service/src/lib.rs @@ -0,0 +1,337 @@ +//! BitFun Relay Service +//! +//! Shared relay logic used by both the standalone relay-server binary and +//! the embedded relay running inside the desktop process. +//! +//! The relay is a stateless HTTP-to-WebSocket bridge: +//! - Desktop clients connect via WebSocket +//! - Mobile clients interact via HTTP POST +//! - The relay forwards encrypted payloads without inspection +//! - Per-room mobile-web static files are managed via `WebAssetStore` + +pub mod admin; +pub mod db; +pub mod relay; +pub mod routes; + +pub use relay::room::{ResponsePayload, RoomManager}; +pub use routes::api::AppState; + +use axum::extract::DefaultBodyLimit; +use axum::routing::{get, post}; +use axum::Router; +use dashmap::DashMap; +use std::collections::HashMap; +use std::sync::Arc; + +// ── WebAssetStore trait ─────────────────────────────────────────────── + +/// Abstract storage for per-room mobile-web static assets. +/// +/// The standalone relay uses `DiskAssetStore` (filesystem-backed), while +/// the embedded relay uses `MemoryAssetStore` (in-memory DashMap-backed). +pub trait WebAssetStore: Send + Sync + 'static { + /// Check if content with this SHA-256 hash exists in the store. + fn has_content(&self, hash: &str) -> bool; + + /// Store content by its SHA-256 hash. No-op if already present. + fn store_content(&self, hash: &str, data: Vec) -> Result<(), String>; + + /// Associate a relative file path within a room to a stored content hash. + fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String>; + + /// Retrieve file content for serving. Falls back to `index.html` if the + /// requested path doesn't exist (SPA routing). + fn get_file(&self, room_id: &str, path: &str) -> Option>; + + /// Check if any web files have been uploaded for this room. + fn has_room_files(&self, room_id: &str) -> bool; + + /// Remove all uploaded web files for a room. + fn cleanup_room(&self, room_id: &str); +} + +// ── MemoryAssetStore ────────────────────────────────────────────────── + +/// In-memory asset store backed by DashMap. Used by the embedded relay. +pub struct MemoryAssetStore { + content_store: DashMap>>, + room_manifests: DashMap>, +} + +impl MemoryAssetStore { + pub fn new() -> Self { + Self { + content_store: DashMap::new(), + room_manifests: DashMap::new(), + } + } +} + +impl Default for MemoryAssetStore { + fn default() -> Self { + Self::new() + } +} + +impl WebAssetStore for MemoryAssetStore { + fn has_content(&self, hash: &str) -> bool { + self.content_store.contains_key(hash) + } + + fn store_content(&self, hash: &str, data: Vec) -> Result<(), String> { + self.content_store + .entry(hash.to_string()) + .or_insert_with(|| Arc::new(data)); + Ok(()) + } + + fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String> { + self.room_manifests + .entry(room_id.to_string()) + .or_default() + .insert(rel_path.to_string(), hash.to_string()); + Ok(()) + } + + fn get_file(&self, room_id: &str, path: &str) -> Option> { + let manifest = self.room_manifests.get(room_id)?; + let hash = manifest.get(path).or_else(|| manifest.get("index.html"))?; + let content = self.content_store.get(hash)?; + Some(content.value().as_ref().clone()) + } + + fn has_room_files(&self, room_id: &str) -> bool { + self.room_manifests.contains_key(room_id) + } + + fn cleanup_room(&self, room_id: &str) { + self.room_manifests.remove(room_id); + } +} + +// ── DiskAssetStore ──────────────────────────────────────────────────── + +/// Filesystem-backed asset store. Used by the standalone relay server. +/// +/// Content is stored in `{base_dir}/_store/{hash}` and symlinked into +/// per-room directories `{base_dir}/{room_id}/{path}`. +pub struct DiskAssetStore { + base_dir: String, + known_hashes: DashMap, +} + +impl DiskAssetStore { + pub fn new(base_dir: &str) -> Self { + let store_dir = std::path::PathBuf::from(base_dir).join("_store"); + let _ = std::fs::create_dir_all(&store_dir); + + let known: DashMap = DashMap::new(); + if store_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&store_dir) { + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + if let Some(name) = entry.file_name().to_str() { + known.insert(name.to_string(), meta.len()); + } + } + } + } + } + } + tracing::info!( + "DiskAssetStore initialized with {} entries from {base_dir}", + known.len() + ); + Self { + base_dir: base_dir.to_string(), + known_hashes: known, + } + } + + fn store_dir(&self) -> std::path::PathBuf { + std::path::PathBuf::from(&self.base_dir).join("_store") + } + + fn room_dir(&self, room_id: &str) -> std::path::PathBuf { + std::path::PathBuf::from(&self.base_dir).join(room_id) + } +} + +impl WebAssetStore for DiskAssetStore { + fn has_content(&self, hash: &str) -> bool { + self.known_hashes.contains_key(hash) + } + + fn store_content(&self, hash: &str, data: Vec) -> Result<(), String> { + let store_path = self.store_dir().join(hash); + if !store_path.exists() { + std::fs::write(&store_path, &data).map_err(|e| e.to_string())?; + self.known_hashes + .insert(hash.to_string(), data.len() as u64); + } + Ok(()) + } + + fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String> { + let store_path = self.store_dir().join(hash); + let dest = self.room_dir(room_id).join(rel_path); + if let Some(parent) = dest.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::remove_file(&dest); + create_link(&store_path, &dest).map_err(|e| e.to_string()) + } + + fn get_file(&self, room_id: &str, path: &str) -> Option> { + let room_dir = self.room_dir(room_id); + let target = room_dir.join(path); + let file = if target.is_file() { + target + } else { + room_dir.join("index.html") + }; + if file.is_file() { + std::fs::read(&file).ok() + } else { + None + } + } + + fn has_room_files(&self, room_id: &str) -> bool { + self.room_dir(room_id).exists() + } + + fn cleanup_room(&self, room_id: &str) { + let dir = self.room_dir(room_id); + if dir.exists() { + if let Err(e) = std::fs::remove_dir_all(&dir) { + tracing::warn!("Failed to clean up room web dir {}: {e}", dir.display()); + } else { + tracing::info!("Cleaned up room web dir for {room_id}"); + } + } + } +} + +fn create_link(original: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(original, link) + } + #[cfg(not(unix))] + { + std::fs::hard_link(original, link).or_else(|_| std::fs::copy(original, link).map(|_| ())) + } +} + +// ── Router builder ──────────────────────────────────────────────────── + +/// Build the relay router with all API, WebSocket, and static-file routes. +/// +/// Both the standalone binary and the embedded relay call this function, +/// passing their own `WebAssetStore` implementation. +pub fn build_relay_router( + room_manager: Arc, + asset_store: Arc, + start_time: std::time::Instant, + db: Option>, + host_version: &'static str, +) -> Router { + let state = AppState { + room_manager, + start_time, + asset_store, + db, + login_rate_limiter: std::sync::Arc::new(crate::routes::auth::LoginRateLimiter::new()), + device_manager: crate::relay::DeviceManager::new(), + }; + + Router::new() + .route( + "/health", + get(move |state| routes::api::health_check_for_host(state, host_version)), + ) + .route( + "/api/info", + get(move || routes::api::server_info_for_host(host_version)), + ) + .route( + "/api/auth/login/challenge", + post(routes::auth::login_challenge), + ) + .route("/api/auth/login", post(routes::auth::login)) + .route("/api/auth/logout", post(routes::auth::logout)) + .route("/api/auth/delegate", post(routes::auth::delegate)) + .route("/api/rooms/{room_id}/pair", post(routes::api::pair)) + .route( + "/api/rooms/{room_id}/command", + post(routes::api::command).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route( + "/api/rooms/{room_id}/upload-web", + post(routes::api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route( + "/api/rooms/{room_id}/check-web-files", + post(routes::api::check_web_files), + ) + .route( + "/api/rooms/{room_id}/upload-web-files", + post(routes::api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route("/r/{*rest}", get(routes::api::serve_room_web_catchall)) + .route("/ws", get(routes::websocket::websocket_handler)) + .merge(routes::sync::sync_router()) + .merge(routes::devices::device_router()) + .layer(tower_http::cors::CorsLayer::permissive()) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + async fn get_json(app: Router, path: &str) -> serde_json::Value { + let response = app + .oneshot( + Request::builder() + .uri(path) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .expect("relay router request should complete"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("relay router response body should be readable"); + serde_json::from_slice(&body).expect("relay router response should be JSON") + } + + #[tokio::test] + async fn router_exposes_health_and_server_info() { + let app = build_relay_router( + RoomManager::new(), + Arc::new(MemoryAssetStore::new()), + std::time::Instant::now(), + None, + "test-host-version", + ); + + let health = get_json(app.clone(), "/health").await; + assert_eq!(health["status"], "healthy"); + assert_eq!(health["rooms"], 0); + assert_eq!(health["connections"], 0); + assert_eq!(health["version"], "test-host-version"); + + let info = get_json(app, "/api/info").await; + assert_eq!(info["name"], "BitFun Relay Server"); + assert_eq!(info["version"], "test-host-version"); + assert_eq!(info["protocol_version"], 2); + } +} diff --git a/src/apps/relay-server/src/relay/device_manager.rs b/src/crates/services/relay-service/src/relay/device_manager.rs similarity index 98% rename from src/apps/relay-server/src/relay/device_manager.rs rename to src/crates/services/relay-service/src/relay/device_manager.rs index a48df54312..3f9b3c98a6 100644 --- a/src/apps/relay-server/src/relay/device_manager.rs +++ b/src/crates/services/relay-service/src/relay/device_manager.rs @@ -272,10 +272,7 @@ mod tests { assert_eq!(mgr.conn_mapping(2), Some(("user-1".into(), "dev-1".into()))); // Closing the active conn still cleans up. - assert_eq!( - mgr.unregister(2), - Some(("user-1".into(), "dev-1".into())) - ); + assert_eq!(mgr.unregister(2), Some(("user-1".into(), "dev-1".into()))); assert!(mgr.online_devices("user-1").is_empty()); } } diff --git a/src/apps/relay-server/src/relay/mod.rs b/src/crates/services/relay-service/src/relay/mod.rs similarity index 100% rename from src/apps/relay-server/src/relay/mod.rs rename to src/crates/services/relay-service/src/relay/mod.rs diff --git a/src/apps/relay-server/src/relay/room.rs b/src/crates/services/relay-service/src/relay/room.rs similarity index 100% rename from src/apps/relay-server/src/relay/room.rs rename to src/crates/services/relay-service/src/relay/room.rs diff --git a/src/apps/relay-server/src/routes/api.rs b/src/crates/services/relay-service/src/routes/api.rs similarity index 97% rename from src/apps/relay-server/src/routes/api.rs rename to src/crates/services/relay-service/src/routes/api.rs index 7ab461c56f..c628f70533 100644 --- a/src/apps/relay-server/src/routes/api.rs +++ b/src/crates/services/relay-service/src/routes/api.rs @@ -55,9 +55,16 @@ pub struct HealthResponse { } pub async fn health_check(State(state): State) -> Json { + health_check_for_host(State(state), env!("CARGO_PKG_VERSION")).await +} + +pub(crate) async fn health_check_for_host( + State(state): State, + host_version: &'static str, +) -> Json { Json(HealthResponse { status: "healthy".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), + version: host_version.to_string(), uptime_seconds: state.start_time.elapsed().as_secs(), rooms: state.room_manager.room_count(), connections: state.room_manager.connection_count(), @@ -72,9 +79,13 @@ pub struct ServerInfo { } pub async fn server_info() -> Json { + server_info_for_host(env!("CARGO_PKG_VERSION")).await +} + +pub(crate) async fn server_info_for_host(host_version: &'static str) -> Json { Json(ServerInfo { name: "BitFun Relay Server".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), + version: host_version.to_string(), protocol_version: 2, }) } diff --git a/src/apps/relay-server/src/routes/auth.rs b/src/crates/services/relay-service/src/routes/auth.rs similarity index 100% rename from src/apps/relay-server/src/routes/auth.rs rename to src/crates/services/relay-service/src/routes/auth.rs diff --git a/src/apps/relay-server/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs similarity index 98% rename from src/apps/relay-server/src/routes/devices.rs rename to src/crates/services/relay-service/src/routes/devices.rs index ee8e255148..29f63756c8 100644 --- a/src/apps/relay-server/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -218,7 +218,9 @@ async fn delete_device( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Disconnect active WS session if any. - state.device_manager.disconnect_device(&user_id, &target_device_id); + state + .device_manager + .disconnect_device(&user_id, &target_device_id); tracing::info!("Device {target_device_id} removed from account {user_id}"); Ok(StatusCode::NO_CONTENT) diff --git a/src/apps/relay-server/src/routes/mod.rs b/src/crates/services/relay-service/src/routes/mod.rs similarity index 100% rename from src/apps/relay-server/src/routes/mod.rs rename to src/crates/services/relay-service/src/routes/mod.rs diff --git a/src/apps/relay-server/src/routes/sync.rs b/src/crates/services/relay-service/src/routes/sync.rs similarity index 100% rename from src/apps/relay-server/src/routes/sync.rs rename to src/crates/services/relay-service/src/routes/sync.rs diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/crates/services/relay-service/src/routes/websocket.rs similarity index 100% rename from src/apps/relay-server/src/routes/websocket.rs rename to src/crates/services/relay-service/src/routes/websocket.rs