agilelabs-fx-docs main topics/frontend-hosting.md

前端托管

本页是 ClientApp 和多前端入口托管的主题页,聚合目录、路径、缓存和后端保护约定。正式规则请优先阅读 框架规范 / 前端项目规范框架规范 / 前端对接规范

适用场景

  • 与后端一起部署一个或多个前端应用。
  • 需要通过 ASP.NET Core 中间件统一托管静态资源与默认页。
  • 需要在前端入口前后插入鉴权、缓存或网关逻辑。

必须遵守

  • 所有前端构建产物都位于明确的 RootPath 下。
  • 每个应用显式声明 PublicPathDistPath,且路径以 / 开头。
  • UseClientApp() 放在明确的请求管道位置。
  • 敏感前端入口的保护由后端或网关负责,不依赖前端路由。
  • 新前端项目的样式栈、图标库、主题模式和项目级包源默认值必须与 前端项目规范 保持一致。
  • 前端对接的 JSON 字段和时间格式必须与正式前端契约规范保持一致。
  • 缓存策略以当前框架实现为准,不把默认行为误写成长期缓存。
  • UseClientApp() 前后涉及的认证、审计和反向代理规则必须在文档中写清楚。

推荐做法

  • 每个前端应用使用独立 PublicPath
  • 发布目录按应用划分,例如 ClientApp/AdminClientApp/Portal
  • 需要登录保护的入口,在 UseClientApp() 前后明确插入认证授权或网关保护。
  • 统一通过反向代理暴露外部路径,不在构建产物里硬编码运行环境 URL。
  • 本地联调用开发代理,生产环境只保留发布目录和路径映射。
  • 前端构建工具的 baseClientAppConfig.Create()PublicPath 保持一致。
  • Browser Route 明确配置 basenameHashRoute 明确配置 hash 模式,不把两种路由混在一起。

典型目录结构

apps/WebSite
├─ ClientApp
│  ├─ Portal
│  │  ├─ index.html
│  │  └─ assets/...
│  ├─ Admin
│  │  ├─ index.html
│  │  └─ assets/...
│  └─ HelpDocs
│     ├─ index.html
│     └─ assets/...
└─ Program.cs
  • RootPath 对应 ClientApp
  • DistPath 对应 PortalAdminHelpDocs 这类子目录。
  • PublicPath 对应浏览器访问路径,例如 /portal/admin/help

更完整的混合仓库结构通常会拆成“前端源码目录”和“宿主发布目录”两层:

repo-root
├─ apps
│  ├─ webapi
│  │  ├─ Program.cs
│  │  ├─ ClientApp
│  │  │  ├─ Portal
│  │  │  ├─ Admin
│  │  │  └─ HelpDocs
│  │  └─ Infrastructure
│  │     └─ Hosting
│  │        └─ Docker
│  ├─ portalweb
│  │  ├─ src
│  │  └─ dist
│  ├─ adminweb
│  │  ├─ src
│  │  └─ dist
│  └─ helpdocsweb
│     ├─ src
│     └─ dist
└─ scripts
   └─ frontend
      ├─ sync-dist-to-clientapp.mjs
      └─ sync-all-apps-to-publish.sh
  • apps/portalwebapps/adminweb 这类目录负责前端源码和本地构建。
  • apps/webapi/ClientApp/... 负责本地开发或直接托管场景下的前端发布目录。
  • apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/... 负责 CI 中 dotnet publish 后的最终打包目录。
  • 多前端项目建议统一用脚本把 dist 同步到 ClientApp,不要在 CI 里手写多段重复 cp

示例代码

示例一:单前端入口,使用 Browser Route

后端注册:

// Program.cs / Startup.cs
services.AddClientApp(options =>
{
    options.RootPath = "ClientApp";
    options.ClientApps.Add(ClientAppConfig.Create("/portal", "Portal"));
});
app.UseStaticFiles();
app.UseClientApp();

前端构建配置:

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  base: "/portal/",
  build: {
    outDir: "dist"
  }
});
// React Router 示例
import { BrowserRouter } from "react-router-dom";

export function App() {
  return (
    <BrowserRouter basename="/portal">
      {/* routes */}
    </BrowserRouter>
  );
}

适用说明:

  • 访问地址通常是 /portal//portal/orders/42
  • Browser Route 依赖后端在未命中静态资源时回退到 index.html,因此更要保证 UseClientApp() 放在合适的位置。
  • 当前框架会把 /portal 自动 301 到 /portal/,可以减少相对资源路径出错。

示例二:同一宿主托管多个前端项目

后端注册多个 ClientApp

services.AddClientApp(options =>
{
    options.RootPath = "ClientApp";
    options.ClientApps.Add(ClientAppConfig.Create("/portal", "Portal"));
    options.ClientApps.Add(ClientAppConfig.Create("/admin", "Admin"));
    options.ClientApps.Add(ClientAppConfig.Create("/help", "HelpDocs"));
});
app.UseStaticFiles();
app.UseClientApp();

前端发布产物落位:

ClientApp/
├─ Portal/    <- 对外路径 /portal
├─ Admin/     <- 对外路径 /admin
└─ HelpDocs/  <- 对外路径 /help

构建结果同步示例:

bash tutorials/samples/fullstack-starter/scripts/sync-clientapp.sh \
  apps/portal/dist \
  apps/api/ClientApp/Portal
bash tutorials/samples/fullstack-starter/scripts/sync-clientapp.sh \
  apps/admin/dist \
  apps/api/ClientApp/Admin

适用说明:

  • 多个前端项目不要共用同一个 DistPath
  • 每个前端项目都应有自己独立的 base,例如 /portal//admin/
  • 如果 /admin 需要登录保护,保护逻辑仍然应该放在后端或网关,不要只依赖前端路由守卫。

如果你希望源码目录和宿主目录分开,比较推荐下面这种对应关系:

apps/portalweb/dist     -> apps/webapi/ClientApp/Portal
apps/adminweb/dist      -> apps/webapi/ClientApp/Admin
apps/helpdocsweb/dist   -> apps/webapi/ClientApp/HelpDocs

在 CI 的发布阶段,再同步到最终发布目录:

apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/Portal
apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/Admin
apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/HelpDocs

示例三:单前端入口,使用 HashRoute

后端注册方式与 Browser Route 一样,只是前端路由模式改成 hash:

services.AddClientApp(options =>
{
    options.RootPath = "ClientApp";
    options.ClientApps.Add(ClientAppConfig.Create("/help", "HelpDocs"));
});
// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  base: "/help/",
  build: {
    outDir: "dist"
  }
});
// React Router 示例
import { HashRouter } from "react-router-dom";

export function App() {
  return (
    <HashRouter>
      {/* routes */}
    </HashRouter>
  );
}

适用说明:

  • 访问地址通常是 /help/#/getting-started
  • HashRoute 的路由片段在 # 后面,后端主要处理 /help/ 这一层入口。
  • 即便使用 HashRoute,静态资源仍然建议通过 base: "/help/" 固定到正确子路径下。

示例四:通过请求管道集中注册

如果项目使用 IRequestPiplineRegister 统一组织中间件,可以把前端托管作为一个明确的注册项:

public class AppConfigure : IRequestPiplineRegister
{
    public RequestPiplineCollection Configure(
        RequestPiplineCollection pipelines,
        AppBuildContext context)
    {
        pipelines.Register("staticfiles-and-clientapp", RequestPipelineStage.BeforeRouting, app =>
        {
            app.UseStaticFiles();
            app.UseClientApp();
        }, stageOrder: 10);

        return pipelines;
    }
}

适用说明:

  • 这个写法适合把 UseClientApp() 的位置固定到可审查、可排序的请求阶段里。
  • 如果项目里还有自定义静态资源、审计或网关逻辑,最好也一起写成命名明确的注册项。

CI 配置方式

多前端项目的 CI 推荐拆成四段:

  1. 前置环境准备。
  2. 多个前端项目并行构建。
  3. 后端执行 dotnet publish
  4. 把所有前端 dist 汇总到发布目录中的 ClientApp,再做镜像打包和部署。

Drone 流水线示例

下面这个结构参考了真实项目里常见的 .drone.yml 写法,核心是“前端分开构建,最后统一汇总”:

kind: pipeline
name: build-pipeline
type: exec

steps:
  - name: docker-config
    commands:
      - mkdir -p ~/.docker
      - cp /root/.docker/config.json ~/.docker/config.json
      - cp -R /root/.kube ~/.kube
      - docker login hub.feinian.net

  - name: build portalweb
    depends_on:
      - docker-config
    commands:
      - docker run -t --rm -v `pwd`:/workdir -v /data/build_share/pnpm_store:/pnpm_store \
        --workdir /workdir/apps/portalweb hub.feinian.net/build/node:24-alpine \
        sh -c "npm install -g pnpm && pnpm install --store=/pnpm_store && pnpm run build"

  - name: build adminweb
    depends_on:
      - docker-config
    commands:
      - docker run -t --rm -v `pwd`:/workdir -v /data/build_share/pnpm_store:/pnpm_store \
        --workdir /workdir/apps/adminweb hub.feinian.net/build/node:24-alpine \
        sh -c "npm install -g pnpm && pnpm install --store=/pnpm_store && pnpm run typecheck && pnpm run build"

  - name: build helpdocsweb
    depends_on:
      - docker-config
    commands:
      - docker run -t --rm -v `pwd`:/workdir -v /data/build_share/pnpm_store:/pnpm_store \
        --workdir /workdir/apps/helpdocsweb hub.feinian.net/build/node:24-alpine \
        sh -c "npm install -g pnpm && pnpm install --store=/pnpm_store && pnpm run build"

  - name: publish backend
    depends_on:
      - docker-config
    commands:
      - docker run -t --rm -v /data/build_share/dotnet:/root/.dotnet -v /data/build_share/nuget:/root/.nuget -v `pwd`:/workdir \
        --workdir /workdir hub.feinian.net/dotnet/sdk:10.0 \
        bash -c "dotnet publish apps/webapi/WebApi.Host.csproj -c Release -o ./apps/webapi/Infrastructure/Hosting/Docker/publish"

  - name: sync client apps
    depends_on:
      - publish backend
      - build portalweb
      - build adminweb
      - build helpdocsweb
    commands:
      - bash ./scripts/frontend/sync-all-apps-to-publish.sh ./apps/webapi/Infrastructure/Hosting/Docker/publish

  - name: package and deploy
    depends_on:
      - sync client apps
    commands:
      - docker build -t hub.feinian.net/demo/webapi:$IMAGE_VERSION \
        --file `pwd`/apps/webapi/Infrastructure/Hosting/Dockerfile \
        `pwd`/apps/webapi/Infrastructure/Hosting/Docker/publish
      - docker push hub.feinian.net/demo/webapi:$IMAGE_VERSION

适用说明:

  • 前端步骤只负责生成各自的 dist,不要在这些步骤里直接复制到发布目录。
  • publish backend 先产出统一发布根目录,后续汇总步骤只对这个目录做增量补充。
  • sync client apps 是整条链路的关键收口点,最适合统一处理 ClientApp 目录结构。
  • 如果有多个前端项目,建议像上面这样拆成多个步骤并行执行,而不是串成一个长步骤。

汇总脚本示例

CI 里最好使用单独脚本把多个 dist 同步到发布目录,而不是在 Drone YAML 中重复写很多 cp 命令:

#!/usr/bin/env bash

set -euo pipefail

publish_root="${1:-./apps/webapi/Infrastructure/Hosting/Docker/publish}"
client_app_root="$publish_root/ClientApp"

sync_app() {
  local source_dir="$1"
  local target_dir="$2"

  rm -rf "$target_dir"
  mkdir -p "$target_dir"
  cp -R "$source_dir"/. "$target_dir"/
}

sync_app "./apps/portalweb/dist" "$client_app_root/Portal"
sync_app "./apps/adminweb/dist" "$client_app_root/Admin"
sync_app "./apps/helpdocsweb/dist" "$client_app_root/HelpDocs"

适用说明:

  • 这个脚本模式和真实项目里 sync-all-apps-to-publish.sh 的职责一致。
  • 如果项目还有额外静态资源汇总,例如门户公共素材、git 快照或诊断文件,也应该在这个脚本里集中处理。
  • 文档页里只保留脚本骨架;具体镜像仓库地址、Kubernetes 命名空间和部署命令应写在项目自己的 CI 文档里。

常见坑

  • 多个前端构建产物铺在同一目录。
  • PublicPath 用相对路径。
  • UseClientApp() 放在会吞掉 API 请求的位置。
  • 认为“前端有登录页”就等于入口安全。
  • 前端构建时忘记设置 base,导致 /assets/... 指向站点根路径。
  • Browser Route 没有配置 basename,导致子路径部署后路由跳转错误。
  • 以为 HashRoute 不需要配置 PublicPath,结果静态资源仍然从错误目录加载。
  • 在 CI 的前端构建步骤里直接改发布目录,导致并行步骤互相覆盖。
  • dotnet publish 和前端产物汇总顺序混乱,最后镜像里缺少 ClientApp 目录。
  • 在 Drone YAML 中手写大量重复复制逻辑,后续增加一个前端入口就要改很多地方。

示例与落地对照

  • gmandarin-backend:同一站点托管多个前端入口,并与自定义 UseStaticFiles(...) 缓存策略并存。

相关页面