Rivet Actors 前端工程规范:React 交互原语、多 Flavor 特性开关与测试策略实践
【免费下载链接】actorsRivet Actors are the primitive for stateful workloads. Built for AI agents, collaborative apps, and durable execution.项目地址: https://gitcode.com/GitHub_Trending/riv/actors
导读
Rivet Actors 的 Web Dashboard(frontend/,即@rivetkit/engine-frontend)是一个面向有状态 Actor 工作负载的控制台,同一套前端构建需同时服务云平台(cloud)、自托管 OSS 与私有化 Enterprise 三种部署形态。本文以仓库内 frontend/CLAUDE.md 为核心,结合 features.ts、agent-mocks.ts 等源码,系统讲解该前端的编码约定:键盘快捷键与异步 Mutation 的统一抽象、渲染期派生状态、嵌入式构建路径、跨 Flavor 特性开关、双形态布局同步,以及基于 Ladle 与 MSW 的组件测试与端到端 Mock 方案。读完本文,你将掌握在 Rivet 仓库内开发 Dashboard 功能时的完整落地姿势与可验证的源码依据。
一、交互原语:统一使用 TanStack 抽象,禁止手写 DOM 监听
Dashboard 大量承载"快捷键 + 异步提交"类交互(如 Actor 详情页的 REPL、状态编辑、构建操作)。仓库规范明确要求:不要自己手写window.addEventListener("keydown", ...),也不要手写useState加载标志 + try/catch/setState。
1.1 键盘快捷键:useHotkey/useHotkeySequence
快捷键统一通过@tanstack/react-hotkeys的useHotkey/useHotkeySequence注册(该依赖已声明于 frontend/package.json 的 dependencies 中)。其关键用法之一是ignoreInputs选项:
- 当焦点位于 input、textarea、contenteditable 等可输入元素内时,应通过
ignoreInputs让快捷键自动失效,禁止手动检查e.target.tagName来做等价判断。 - 原因很直接:手写监听不仅要处理注册/注销的生命周期,还要自行维护输入态判断与快捷键冲突,而 TanStack 抽象把这些都收敛到选项里,语义更清晰、更不容易漏掉边界。
// 推荐:声明式注册 + ignoreInputs useHotkey("mod+k", () => openCommandPalette(), { ignoreInputs: true });1.2 异步用户操作:一律走useMutation
任何带有 pending / loading / error 状态的用户触发操作(创建 Actor、删除构建、更新配置、提交表单等),都必须用@tanstack/react-query的useMutation封装,UI 通过mutation.isPending/mutation.mutate()驱动:
const mutation = useMutation({ mutationFn: (id: string) => deleteActor(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["actors"] }), }); return ( <Button isLoading={mutation.isPending} onClick={() => mutation.mutate(actor.id)}> Delete </Button> );这样做的收益是:pending 状态、错误对象、重试与失效缓存(invalidateQueries)都交给 React Query 统一管理,UI 层不再散落手工状态机。仓库中大量页面(如 actor-stop-button.tsx、各*-form.tsx)都遵循这一模式。
二、状态管理:渲染期派生,禁用 useEffect 作为逃生通道
规范的核心约束是:状态应该在渲染期间派生,而不是用useEffect去同步。即:
- 如果一个值可以由 props 或现有 state 计算得到,就在组件体内直接计算,不要"先渲染旧值、再用 effect 补一刀";
- 不要把
useEffect当作通用逃生通道(escape hatch)使用; - 如果确实找不到其他方案、认为某个 effect 不可避免,先停下来向用户/维护者确认后再添加。
这条规则直接决定了 Dashboard 中大量列表、过滤、派生统计(例如 namespace 下的 runner 数量、builds 排序)的写法:先计算再渲染,而不是渲染后同步,避免闪烁、重复渲染与 effect 竞态。相关范式可参考 engine-namespace-landing.tsx 中"渲染期对 builds 排序、对 runner 配置计数"的实现。
三、嵌入式构建:BASE_URL=/ui/ 是唯一正确的路径策略
Rivet Engine 会把 Dashboard 前端嵌入到rivet-engine产物中对外服务,这要求构建时指定资源基路径。规范规定:
- 嵌入式构建必须使用
pnpm build:engine(等价于npx turbo build:engine -F @rivetkit/engine-frontend); - 该命令会设置
BASE_URL=/ui/(见 frontend/package.json 的build:enginescript); - 严禁通过给 engine 添加根路径
/assets/*路由来"补偿"错误的构建基路径——发现问题时应修正嵌入式构建的 base,而不是在 engine 侧打补丁。
这条约束保证了同一份源码既能以根路径构建出独立 Dashboard(pnpm build),也能以/ui/基路径嵌入 engine 自托管控制台,两套产物互不干扰。
四、一份构建服务三种形态:Feature Flags 架构
Dashboard 用一套前端构建同时服务 cloud / OSS / enterprise 三种部署 flavor,其机制是features.*特性开关。所有开关集中定义在 frontend/src/lib/features.ts 一处,调用点只读取布尔值:
import { features } from "@/lib/features"; if (features.platform) { // cloud-platform-only UI }从 features.ts 源码可以看到完整的开关解析链:
- 运行时事实来源是
VITE_FEATURE_FLAGS环境变量(逗号分隔的开关名列表); - 开发环境(
import.meta.env.DEV)下localStorage的FEATURE_FLAGS键优先于环境变量,用于本地模拟任意 flavor; - 未设置(
undefined)表示所有开关全开,即完整云构建;显式给出空字符串或列表则只启用所列开关; - PostHog 标志(仅云)是纯增量合并:只能把某个开关打开,永远不能关闭环境变量/localStorage 已启用的开关(见 lib/posthog.ts 的
getPosthogEnabledFeatureFlags); - 开关之间存在隐含依赖,在 features.ts 内编码,而非在每个调用点处理。例如
platform隐含auth、acl隐含于platform、compute要求platform、captcha要求auth。
当前仓库中定义的开关及其语义(依据 features.ts 与 .claude/reference/feature-flags.md):
| 开关 | 语义 |
|---|---|
auth | Dashboard 提供登录/注册流程(不代表"engine 需要凭据") |
platform | 云平台栈:publishable-token 端点、billing、projects、多租户;隐含auth |
acl | engine 在公网端点强制 token 鉴权;platform隐含,企业版单独开启 |
billing | 计费 UI |
captcha | 认证表单上的 Turnstile 验证码;要求auth |
compute | Rivet Compute(托管池)UI:namespace 部署、日志入口、Rivet provider 选项;要求platform |
byoc | Bring Your Own Cloud:创建项目流程中的 BYOC 选项、集群列表与集群页;要求platform |
services | 托管服务(Durable Streams):产品选择器 Services 分区、onboarding 路径、namespace settings 的 Services 页签;不依赖platform |
support/branding/datacenter/danger-zone | 帮助入口、品牌装饰、数据中心相关 UI、危险操作(features.dangerZone) |
三种 flavor 的开关组合大致为:cloud = 全开;OSS = 关闭auth/platform/acl;enterprise = 开启acl、关闭auth/platform(engine 强制鉴权但没有登录 UI)。注意compute在云上也是按环境显式 opt-in的(各 Railway 服务在VITE_FEATURE_FLAGS中自行添加),并非继承云默认全开集合。
4.1 何时新增开关:能力命名 + 防蔓延
按 feature-flags.md 的约定,只有满足以下条件才新增开关:功能在至少一种 flavor 上不可用/受限/行为不同,或引入了某个 flavor 可能整体关闭的较大 UI 表面(整页、面板、设置区、子系统)。小范围的通用改动(bug 修复、文案、布局打磨)不加开关;命名以能力为准(如billing、support),而不是以部署形态命名(如enterprise-only)——因为 flavor 是由开关组合出来的,反过来不行。不确定是否需要开关时,先与用户确认,因为一旦各 flavor 依赖上开关就难以移除。
4.2 跨 Flavor 测试:OSS 是必测项
规范强调:任何前端改动完成前,至少要在 OSS 与 cloud 两种 flavor 上验证。原因是 OSS 关掉了最多的能力(auth/platform/acl),任何假设云上下文(org/project 参数、登录会话、云数据提供器)的代码都会在 OSS 下静默损坏,而 OSS 的 namespace 下拉、侧边栏、context switcher、onboarding 与云走的是完全不同的代码路径。
无需重启 dev server,在浏览器控制台切换 flavor 即可:
// OSS 自托管:全部关闭 localStorage.setItem("FEATURE_FLAGS", ""); location.reload(); // 完整云:全部开启(参考 frontend/.env.local 中的注释清单) localStorage.setItem( "FEATURE_FLAGS", "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy,byoc,services", ); location.reload(); // Enterprise:开启 acl,无登录 UI localStorage.setItem("FEATURE_FLAGS", "acl,branding,support,datacenter,danger-zone"); location.reload();测试完毕后执行localStorage.removeItem("FEATURE_FLAGS")恢复环境变量默认。也可以用JSON.stringify(features)确认当前激活的开关集合。原理见 features.ts 第 5-7 行:dev 构建中localStorage优先于VITE_FEATURE_FLAGS。
五、双形态布局必须同步:OSS 与 Platform 不允许视觉分叉
由于 OSS 与 cloud 在同一套代码里走不同路径(engine 路由 vs 云路由、不同数据提供器),很容易出现"同一屏幕两个样子"。规范明确:OSS 与 platform 的布局不得视觉分叉。仓库中有两条已知的平行配对,改动时必须成对维护:
- namespace 落地页:engine 端 engine-namespace-landing.tsx 与云端 actors-grid.tsx,两者共享
ActorBuildCard/ActorGridCardSkeleton呈现组件(源码注释明确要求保持视觉同步,并指出云端的 Deployments 区块与日志链接是 OSS 有意省略的部分); - namespace 设置抽屉:settings-drawer.tsx 是一个 flavor 感知组件,engine 形态只展示 Namespace 分区(Account/Project/Billing/Organization 等云专属导航项被隐藏)。
维护守则:改动配对中的一侧,必须镜像另一侧;优先抽取共享的呈现组件,而不是复制 markup。
5.1 路由行为:无 Actor 选中时不自动跳转
Engine 与 cloud 的 namespace index 路由在没有选择 Actor 名(n参数)时都渲染 Actor-grid 落地页,规范禁止"自动重定向到第一个 build/actor"。只有当用户选中某个 build(设置n)后才显示 Actor 列表/详情。这保证了跨 flavor 一致的信息架构,也避免强制改变用户心智模型。
六、Ladle Story 规范:为真实状态写故事,而非为属性组合
仓库用 Ladle。
三条硬性准则:
- 故事化集成单元,而不是薄包装。如果组件只是某个原语(如三种 label 变体的
<Badge>)的薄封装,不为它写 story——应为"把它与其他状态组合起来的父组件"写。有趣的状态都出现在数据形态交互处:行 + 单元格 + tooltip、表单 + 校验 + 提交。"三个几乎相同的渲染"是噪声,不是覆盖率。 - 用贴近真实 API 的 fixtures 驱动,而不是属性排列。fixtures 要镜像真实后端响应:空结果、单条、多区域、部分失败、混合类型。每个 story 回答的问题是"后端返回 X 时长什么样?"。逐一列举属性组合会让 story 通过设计评审,却漏掉真实 bug——比如空 endpoint 集合落入 "Multiple endpoints" 分支。示例 stories 文件中对 HTTP 500 / 502、连接错误、非法 SSE payload 等错误类型的真实 JSON fixture 就是典型做法。
- 覆盖这个组件真实出过 bug 的状态。修复视觉 bug 时,回归用例就固化成 story;如果一个状态无法改变行为,那就不需要再写 story。
此外:如果写 story 需要 mock 路由 loader、auth 或整套数据提供器栈,就跳过 story。优先重构组件把输入收成 props(story 自然免费获得),或者通过运行中的 dashboard 的父路由来测试;严禁在 story 内 stubuseLoaderData/useRouteContext,该路径腐化极快。
七、Dev 端到端 HTTP Mock:MSW +?mock=1驱动错误 UI
Dashboard 提供了仅限开发环境的端到端 HTTP Mock 能力,用于在不搭建真实 engine 状态的情况下演练错误 UI。实现位于 frontend/src/lib/agent-mocks.ts,并受import.meta.env.DEV门控(见源码第 54 行,生产包完全不受影响——import("msw/browser")是 dev 门后的动态导入)。
7.1 启动与 API
- 给任意 dashboard URL 追加
?mock=1,应用启动时即初始化 MSW worker(maybeStartAgentMocks); - 然后在 DevTools / agent-browser 控制台使用两个全局 API:
// 注册一条 mock:pattern 是 MSW 路径匹配器 window.__rivetMock("*/actors/:id/kv/keys/*", { status: 503, body: { group: "guard", code: "service_unavailable", message: "..." }, }); // 清空所有 mock window.__rivetClearMocks();7.2 关键行为(源码级细节)
- 支持 method 与延时:MockSpec 可带
method(get/post/put/delete/patch,默认 get)与delayMs;delayMs会先delay()再返回响应,从而让触发它的 query 长时间停留在 pending 状态,便于检查 loading 骨架屏(见 agent-mocks.ts)。 - 持久化到 sessionStorage:mock 会写入
__rivetAgentMocks键并在刷新后恢复(worker.start时重建 handlers),契合"设置 mock → 刷新重放 query"的常见 agent 测试工作流;__rivetClearMocks同时清空存储并resetHandlers()。 - 未处理的请求一律 bypass(
onUnhandledRequest: "bypass"),不会误伤正常请求。 - 加载骨架示例:用
delayMs挂住响应即可:
window.__rivetMock("*/actors/names", { status: 200, body: { names: {} }, delayMs: 10000 });7.3 生产安全
由于 mock 激活被import.meta.env.DEV硬门控、MSW 模块在 dev gate 之后才动态导入,生产 bundle 中不存在 mock 代码与 worker,无需担心?mock=1泄露到线上环境。
八、落地清单:一份改动从编码到验收的完整路径
结合以上规范,一次典型的 Dashboard 前端改动应经过:
- 编码:快捷键用
useHotkey/useHotkeySequence(配ignoreInputs);异步操作包useMutation;派生状态在渲染期计算,非必要不写useEffect(确实不可避免时先征询确认)。 - 构建:若改动最终要嵌入 engine,用
pnpm build:engine验证/ui/基路径产物,不在 engine 侧加根路径路由。 - Flavor 验证:在浏览器控制台用
localStorage.setItem("FEATURE_FLAGS", "")切到 OSS、用移除键恢复云默认,OSS 与 cloud 至少各验证一遍;若涉及features.*条件渲染的表面(sidebar、context switcher、onboarding、settings、auth、billing),逐个 flavor 在浏览器里实际操作。 - 布局同步:命中 namespace 落地页或设置抽屉等平行配对时,镜像另一侧实现,优先抽共享呈现组件。
- Story:新组件有值得教的状态时补 Ladle story(真实 fixtures + 回归 bug 状态),薄包装或需 stub 路由/auth 的跳过。
- Mock 演练:需要展示错误/空/加载态时,用
?mock=1+__rivetMock端到端演练后再收尾。
这套约定把"多形态单构建"的复杂度收敛为少数几条可复用的抽象(features 开关、Mutation、MSW 门控、平行配对),也是仓库内所有前端贡献者共同遵循的工程基线,相关完整说明可继续阅读 .claude/reference/feature-flags.md 与 frontend/CLAUDE.md。
【免费下载链接】actorsRivet Actors are the primitive for stateful workloads. Built for AI agents, collaborative apps, and durable execution.项目地址: https://gitcode.com/GitHub_Trending/riv/actors
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考