Site UI 模块
本页写给要为发行版开发自己公开页面的前端开发者。如果配置和品牌设置已经够用,请直接看发行版定制。
部署可以替换访客看到的公开页面(首页、账号页面的外壳、法律条款页),而不用 fork 控制台。靠的是构建时 UI 模块:一小组 React 组件,构建前端镜像时编译进去,和共享应用并存,而不是取代它。
只有一个 Next.js 应用、一个控制台、一套会话。发行版只需提供一个小小的 UI 模块,构建时会把它编译进去。
hybridinference @ C
├── neutral backend image
└── generic Next.js frontend
+ distribution UI module @ U → the distribution's own frontend image
这里的 C 是上游源码的提交,U 是发行版 UI 源码的提交。构建发布版本时,两者都要锁定。模块不会在运行时拉取或替换;改组件、样式表或编译进去的文案,都需要重新构建前端镜像。模块读取的运行时品牌配置,仍然在运行时生效。
模块能改什么、不能改什么
模块负责 |
共享应用保留 |
|---|---|
模块导出的首页 |
仪表盘、聊天、管理后台、团队和授权页面,以及 |
账号页面外壳,以及每个字段各部分的位置 |
账号表单、字段、验证与提交请求 |
模块自带的法律文本和注册确认项 |
注册同意步骤、注册时记录的那一个 |
它为账号页面写的文案 |
schema、错误码、会话处理和重定向规则 |
公开页面的标题,以及它渲染的页面用什么语言 |
所有控制台页面的标题和语言 |
它自己的样式表和设计资源 |
运行时品牌文档与 |
模块决定不了谁能进来:账号页面的业务逻辑、注册开关、邮箱验证要求和权限规则都在共享应用里,不属于这个接口。
共享应用还负责会话存储、代理规则,以及 AuthField / AuthNotice / AuthLoading 的无障碍行为。模块不能注册路由,也不能接管上面没列出的页面。新增 /pricing 这样的路径、重新设计控制台或增加认证方式,都超出了这个接口的范围,需要改应用本身。
宿主接口(host facade)向模块提供公开的站点配置、解析后的品牌配置、会话状态,以及受支持的展示类型和辅助函数。页面可以根据会话状态决定显示登录链接还是控制台链接;但这个接口不提供登录/退出、token 或任何认证操作。TypeScript 接口以 apps/frontend/src/site-ui/contract.ts 为准,可用的导入见 apps/frontend/src/site-ui/host.ts。
模块可以导入什么
模块可以导入 @site-ui/host、react、react/*、react-dom、next、next/* 以及模块自己的文件。相对导入不能跳出模块目录。其他任何导入(尤其是 @/...)都会导致构建失败。
构建时,webpack 每解析模块的一个请求,都会对照这份清单检查一次,Next.js 的每一次编译都是如此;判断依据是请求最终解析到的真实路径,而不是它的写法。下面这些情况同样会导致构建失败:
指向模块之外的相对路径或符号链接;
借助上级目录跳出框架包的子路径,例如
next/../../src/site-ui/routes;指向模块之外的样式表
@import或url();针对模块外目录的计算式导入,比如
require.context、import.meta.webpackContext,或参数为模板字符串的import();data:或file:URI,以及 Node.js 内置模块;Next.js 自带 loader 以外的内联 loader,例如
!!raw-loader!./notes.txt;模块内部
node_modules目录中的任何内容。模块的依赖来自应用的锁文件,暂存步骤也不会复制该目录。
Next.js 编译器会往模块代码里注入辅助库 @swc/helpers 和 styled-jsx,构建也放行它们,并从 Next.js 自身解析。模块本身不需要按名字导入它们。
源码检查能更早发现它看得到的问题:暂存模块时会运行,每次构建、类型检查和测试之前也会运行。它用 TypeScript 解析器解析脚本,读取样式表,一次列出所有问题。test/、tests/、__tests__/ 或 __mocks__/ 下的文件,以及 vitest、jest 或 playwright 的配置文件,只要生产代码没有导入,就不受检查;但如果 client.tsx 导入了某个测试辅助文件,它就和其他文件一样要检查。暂存步骤要复制的每个符号链接,都必须解析到模块内部。
模块是一个目录
my-ui/
manifest.json identity: id, site_ui_api, locale
client.tsx the components (required)
server.ts required entry; optional locale and metaMessages
styles.css the module's stylesheet (required, may be empty)
public/ assets, under public/site-assets/<id>/
... the module's own components, however it likes
styles.css 由宿主加载:根布局在每个页面上通过生成的桥接文件导入它,并排在应用自身样式表之后,因此优先级相同时,模块规则会覆盖应用规则。模块不必自行导入它。
server.ts 必须存在,可以仅包含 export {};。它的两个导出 locale 和 metaMessages 都是可选的;参见文档语言与页面标题。
client.tsx 的导出项:
导出项 |
必填 |
含义 |
|---|---|---|
|
是 |
具名的对象字面量 |
|
否 |
在 |
|
否 |
|
|
否 |
每个账号字段的标签、控件、提示、错误和操作放在哪里。 |
|
三个要么都提供,要么都不提供 |
|
|
三个要么都提供,要么都不提供 |
模块的法律文本,用于 |
|
三个要么都提供,要么都不提供 |
注册同意步骤要求访客确认的内容:一个非空的 |
|
否 |
已弃用:为兼容而保留的共享表单类名映射,整个 API v1 期间都继续支持。 |
|
否 |
部署自己为账号页面写的文案。 |
宿主只读取具名导出。客户端入口如果有默认导出,构建会失败;同一个入口有第二个文件(比如 client.tsx 旁边还有 client.js)也会失败,因为打包器、类型检查器和测试工具补全扩展名的顺序各不相同。服务端入口可以保留默认导出,宿主不会读它。
npm run type-check 和 next build 会对照 contract.ts 里的契约检查导出:client.tsx 对照 SiteUiClientModule,server.ts 对照 SiteUiServerModule。导出的类型不对会报错,并指出是哪个导出;法律条款组不完整、consentItems 为空,也同样报错。模块加载时,宿主还会再查一遍那些被类型断言或 any 瞒过编译器的问题:组件导出其实不是组件、法律条款组不完整,以及 consentItems 为空、id 重复,或者有条目缺少 id 或 label。出现其中任何一种,模块都不会加载。
next build 不会加载模块。 每个页面都按需渲染,因此构建过程不会执行任何客户端模块。只有加载时检查才能发现的错误可以顺利通过构建,却会在第一次请求时失败;此后每个页面都返回 HTTP 500。发布每个镜像前都要做冒烟测试:连同网关一起启动它,并请求 /、五个账号页面和 /terms。
不导出某个可选项,是有意的选择,不是遗漏。Landing: null 表示“这里用控制台的首页”;不导出 AuthFrame 表示“把账号页面放进控制台的容器里”。两个都不导出的模块什么也不替换,开发过程中这样完全没问题。
外壳负责绘制整个页面。AuthFrame 和 TermsFrame 自己渲染页头、<main> 和页脚。只画出一张卡片的外壳,会让五个账号页面都没有页头和页脚,宿主也无法把它们补回来,所以要先把这一点做对。
AuthFrame 必须渲染传进来的共享表单 children,并保留传入的 topbar 和 legal 节点。它拿到的标题和页面标识,让同一个外壳可以用在五个账号路由上。完整的外层页面由它负责,所以宿主不会在它外面再加页头或页脚。
法律条款组要整体取舍。发布自己条款的模块,要同时导出 TermsFrame、TermsContent 和 consentItems;三个都不导出的,沿用控制台的条款和它的四个确认项。访客会在两个地方看到条款,宿主在这两处都渲染 TermsContent:一是 /terms,作为 TermsFrame 的 children;二是注册同意步骤,这里模块的 consentItems 会取代控制台的确认项。访客同意的文本,就是站点发布的文本。
TermsContent 只渲染法律正文,即各章节以及日期之类的前言;页面标题由外壳负责。在 /terms 上,它收到的 headingLevel 为 2、compact 为 false;在注册同意步骤的滚动框中,headingLevel 为 3、compact 为 true。TermsFrame 负责绘制包裹它的页面,并且必须渲染其 children。在 /terms 上,请为每个章节设置 terms-s 锚点:账号页面的隐私链接指向 /terms#terms-s5。设置 manifest 中的文件路径不能替代这里的法律文档。
请用 ConsentItems 类型声明 consentItems,并为每个条目设置唯一的 id。普通的 ConsentItem[] 无法通过类型检查,因为它不能保证至少有一个条目。访客把条款读到末尾后,每个条目才会解锁;Continue 按钮要等所有条目都勾选后才可用。后端只记录一个 accepted_tos 标志,注册请求只有在每个条目都勾选之后才会发送它。
authMessages 只改文案,不改校验规则,也不改必填字段。宿主只保留 AUTH_MESSAGE_KEYS 里声明过、且值为字符串的键,其余内容在页面读取之前就会丢弃,所以写给控制台页面的文案(/authorize 的 auth.authorize.*、/chat 的 chat.*)不会生效。没提供的键沿用共享的默认文案。请保留每条消息里的插值变量。模块可以为自己的页面和支持的键做本地化,但这并不会翻译控制台的所有页面。
文档语言与页面标题
服务端入口里可选的 locale,决定模块渲染的那些路由上 <html lang> 的值:导出了 Landing 就是 /,导出了 AuthFrame 就是五个账号页面,导出了法律条款组就是 /terms。其余路由(控制台本身,以及模块没有提供对应导出的公开路由)都归控制台,语言为 en。locale 为空或不写时,所有路由都是 en。这个属性不只在首次响应里生效,客户端导航时也会跟着更新。
可选的 metaMessages 为七个公开路由提供标题和描述文案。它的键是 meta.<page>.title 和 meta.<page>.description,其中 <page> 为 home、login、signup、forgot、reset、verify 或 terms;contract.ts 中的 META_MESSAGE_KEYS 列出了全部键。宿主会像过滤 authMessages 一样过滤它,丢弃未声明的键和非字符串值。meta.home.title 是完整的文档标题;其他标题显示为 <title> | <site name>,与控制台自身页面一致。每个值都可以使用 {app_name}。
模块未提供文案的页面保留默认值:站点自身的标题和描述,/terms 则为“Terms of Service”。标题与模块渲染哪些路由无关。控制台页面保留英文标题,图标来自运行时品牌配置。
使用模块构建镜像
官方前端 Dockerfile 通过 Buildx 命名上下文接收模块:
# The neutral image. The command is unchanged, and needs no new arguments.
docker build -f deploy/docker/Dockerfile.frontend -t local/frontend .
# A module from this repository, for development and for the public example.
docker buildx build -f deploy/docker/Dockerfile.frontend \
--build-context site-ui=./distributions/example/frontend/site-ui \
--build-arg SITE_UI_API=1 \
--load -t local/frontend:example .
# A distribution's own repository, at a pinned commit.
docker buildx build -f deploy/docker/Dockerfile.frontend \
--build-context "site-ui=<UI_REPOSITORY>#<U>:frontend" \
--build-arg SITE_UI_SUBDIR=site-ui \
--build-arg SITE_UI_API=1 \
--tag "<FRONTEND_IMAGE>" --push "<CORE_REPOSITORY>#<C>"
输入 |
默认值 |
含义 |
|---|---|---|
|
内置标记 |
没有外部模块,使用默认 UI |
|
|
上下文中的模块目录 |
|
未设置 |
使用外部模块时必填,必须为 |
发行版也可以把整个 frontend/ 目录树作为上下文传进去,再用 SITE_UI_SUBDIR=site-ui 选出模块。这样,模块和相关的构建输入都在同一棵锁定了版本的源码树里。
外部上下文未包含模块时,构建会失败。不能悄悄回退到默认 UI,否则发布的首页会变回控制台页面,构建日志却毫无提示。
反过来也会失败:没有提供 --build-context site-ui=...,却设置了 SITE_UI_API 或不等于 . 的 SITE_UI_SUBDIR,构建会停止并报错指出缺少的上下文,而不是用默认 UI 顶替模块。
本地开发
同样的暂存步骤也可以在本机运行,不需要 Docker。先启动一个网关;如果它的地址不是 http://backend:8080,就把 BACKEND_INTERNAL_URL 设为它的地址。应用在运行时依赖 /site-config 这一点不变:
cd apps/frontend
node scripts/site-ui/prepare-module.mjs prepare \
--app . --context ../../distributions/example/frontend/site-ui \
--subdir . --into src/site-ui/external --api 1
SITE_UI_DIR="$PWD/src/site-ui/external" SITE_UI_API=1 \
SITE_ASSETS_DIR="$PWD/src/site-ui/external/public/site-assets" npm run dev
# For build or type-check, pass the same module selection variables.
暂存步骤会先清空 --into 再复制模块,因此 --into 必须是一个独立目录:不能与 --context 重叠,也不能等于或包含 --app。
如果不想暂存副本,可以用 SITE_UI_DIR 和 SITE_UI_API 直接指定模块:
SITE_UI_DIR=/path/to/my-ui SITE_UI_API=1 \
SITE_ASSETS_DIR=/path/to/my-ui/public/site-assets npm run dev
SITE_UI_DIR 如果是相对路径,总是相对 apps/frontend 解析,不管命令在哪个目录下执行。Tailwind 除了扫描 src/,还会扫描所选模块的目录,所以即使某个工具类只在 src/ 之外的模块里用到,也照样会生成。
上述变量仅作用于当前命令,不会修改 shell 配置。若要恢复默认 UI,停止服务器后执行:
env -u SITE_UI_DIR -u SITE_UI_API -u SITE_ASSETS_DIR npm run dev
本地开发时,模块资源通过 SITE_ASSETS_DIR 提供;构建镜像时会自动打包这些资源。
暂存步骤生成的环境文件是给 Dockerfile 用的,npm 不会自动读取。暂存目录留着不删,之后的命令也不会因此选中它。
无论用哪种方式,解析器都会写出 src/site-ui/active/ 和 tsconfig.generated.json,这样打包器、类型检查器和测试工具读到的都是同一个选择结果。这些文件是自动生成的,不要手工修改。
npm run type-check 和 next build 都使用 tsconfig.generated.json 做类型检查。它通过入口的导入关系覆盖模块的生产代码,并把模块目录排除在文件匹配范围之外;应用也完全不会对模块执行 lint。模块的测试和工具链应由发行版自己的仓库负责检查。没有被任何文件导入的声明文件(例如包含环境声明 declare module 的文件)同样不在该导入图中:请在入口中用 /// <reference path="./types.d.ts" /> 引用它。
资源
模块的图片放在 public/site-assets/<module-id>/。构建镜像时会把它们打包到 standalone 服务器旁边的 site-assets/<module-id>/,而不是 public/:Next.js 在所有路由之前就会直接提供 public/ 里的文件,放在那里就会绕过 /site-assets 路由。下面三条规则都有强制检查:
只能放在模块自己的 id 下。放在别处,构建就会失败。正是这条规则保证了“两个模块不会写到同一路径,也都不能遮住应用自己的文件”。
不允许冲突。 模块资源与应用已有路径冲突时构建失败,不会静默覆盖。
资源由路由提供。
/site-assets/*只提供图片文件(AVIF、GIF、ICO、JPEG、PNG、SVG 和 WebP,最大 20 MB),附带五分钟的重新验证缓存策略和nosniff,并通过Content-Security-Policy头把 SVG 放进沙箱。构建产物里如果public/site-assets/<module-id>/下还留有文件,构建就会失败。要测试 URL,而不是只看文件在不在。
运行时先读 SITE_ASSETS_DIR,再读镜像里自带的那份。挂载了品牌目录的部署,可以继续覆盖模块的文件;什么都不挂载的部署,也能提供完整的站点。
覆盖按文件逐个生效,且 SITE_ASSETS_DIR 必须是绝对路径。构建会把相对符号链接原样复制为链接,目标保持不变,因此留在模块内部的链接在镜像中仍然有效。目标位于所在目录之外的链接,路由不会提供。
模块组件出错时
抛出异常的模块组件(Landing、AuthFrame、fieldLayout、TermsFrame 或 TermsContent)只会影响它自己所在的页面。每个组件都在各自页面内渲染,应用的路由错误页(app/error.tsx)会用一条中性的提示信息和一个 Try again 按钮替换该页面。如果该路由的外层框架由模块绘制,提示信息会带上控制台的页头和页脚。模块的条款或确认项渲染失败时,错误页绝不会改用控制台的条款或确认项。控制台页面自身的渲染错误也以同样方式显示在控制台外层框架内。
React 在服务器端不会渲染错误边界,因此出错页面的首次响应仍然是错误:HTTP 500;对于在 <Suspense> 内渲染的账号页面(/login、/reset-password 和 /verify-email),则是带加载状态的 HTTP 200。随后浏览器会重新渲染页面并显示提示信息。仅凭状态码无法证明页面正常。
未通过加载时检查的模块则不同:根布局会加载模块,因此每个页面都会随之失败。
兼容性
添加模块不会改变控制台。没有导出
AuthFrame时,五个账号页面留在控制台的容器里;无论是否导出,共享页面、schema 和会话处理都不受影响。移除可选导出是安全的。模块不再导出
Landing后,首页就恢复成控制台的首页。法律条款组只能整体移除。 同时去掉三个导出会恢复控制台的条款和确认项;只去掉其中一个会导致类型检查失败。
重命名或删除导出、改变某个 prop 的含义,或者把某个
data-auth值挪作他用,都是破坏性变更,需要升级 API 版本号。重命名authMessages或metaMessages里的键,或者改变键的插值变量,也一样。新增可选键或属性是兼容变更。
SITE_UI_API 用于选择 Site UI 接口版本。如果模块声明的版本不是当前源码支持的版本,解析器会拒绝构建,避免产出双方都不支持的组合。
模块 API 不提供共享的公开页面布局;模块内部的布局可以自由组织。
测试模块
校验和镜像构建流程归共享仓库负责。下面这些检查用的是内置的中立模块和公开示例。
cd apps/frontend
npm run lint
npm run type-check
npm test
检查自己的模块时,传入与本地开发相同的模块选择变量。测试共享表单的正常、无效、加载和键盘操作状态,以及模块提供的每个公开页面,并检查控制台是否受到模块样式影响。
然后对构建出的镜像做冒烟测试:每个页面都会读 /site-config,所以要连同网关一起启动,再请求 /、五个账号页面和 /terms。next build 不会加载模块,所以这里才是加载时检查第一次真正运行的地方。
在仓库根目录,用同一套上游构建流程构建中立镜像和公开示例:
docker buildx build -f deploy/docker/Dockerfile.frontend \
--load -t local/frontend:neutral .
docker buildx build -f deploy/docker/Dockerfile.frontend \
--build-context site-ui=./distributions/example/frontend/site-ui \
--build-arg SITE_UI_API=1 --load -t local/frontend:example .
docker run --rm --entrypoint cat local/frontend:neutral /app/site-ui-manifest.json
docker run --rm --entrypoint cat local/frontend:example /app/site-ui-manifest.json
第一个 manifest 里必须是 kind: "neutral",第二个必须是 id: "example"。构建这些镜像需要 Docker,还需要能联网下载依赖。要运行完整应用,还需要网关在运行时提供 /site-config 端点。
控制台或示例模块每次改动时,本仓库的 CI 都会跑同样的这两个构建,并检查示例镜像如何提供它的资源。这只覆盖示例:你发行版自己的镜像和资源,也要自行构建和测试。