feat: serve website at root path

This commit is contained in:
boos
2026-06-06 05:59:48 +08:00
parent 03046152fd
commit 5ef017c54e
6 changed files with 155 additions and 60 deletions
+1
View File
@@ -5,6 +5,7 @@
data/ data/
node_modules/ node_modules/
website/.next/ website/.next/
website/out/
dist/ dist/
release/ release/
*.log *.log
+4 -7
View File
@@ -401,7 +401,7 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。 > **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`,可单独部署为 Next.js 服务;BoosAPI 管理台仍由 Rust 静态 serve。若把官网部署在根域名,可设置 `BOSSAI_BACKEND_URL=http://127.0.0.1:4000`,Next 会把 `/boos`、`/user/*`、`/api/*`、`/v1/*`、`/workflow-ui/*` 等路径反代回 Rust 后端。 > 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
--- ---
@@ -468,18 +468,15 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
# 首次安装 # 首次安装
npm --prefix website install npm --prefix website install
# 本地开发 # 本地开发官网
BOSSAI_BACKEND_URL=http://127.0.0.1:4000 npm run website:dev npm run website:dev
# 类型检查与生产构建 # 类型检查与生产构建
npm run website:typecheck npm run website:typecheck
npm run website:build npm run website:build
# 生产运行
BOSSAI_BACKEND_URL=http://127.0.0.1:4000 npm run website:start
``` ```
官网默认只渲染 `/`,后台相关路径由 `website/next.config.ts` 反代到 BoosAPI Rust 服务。 `./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建 ### 生产单二进制构建
+51
View File
@@ -282,6 +282,55 @@ build_frontend() {
ok "前端构建完成 → dist/web/" ok "前端构建完成 → dist/web/"
} }
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
return 0
fi
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
}
ensure_workflow_ui_for_embedding() { ensure_workflow_ui_for_embedding() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)" log "跳过 Workflow UI 构建检查(--skip-frontend)"
@@ -374,12 +423,14 @@ cleanup_started_server() {
if [ "$FRONTEND_ONLY" -eq 1 ]; then if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend build_frontend
build_public_website
log "跳过启动(--frontend-only)" log "跳过启动(--frontend-only)"
exit 0 exit 0
fi fi
if [ "$BACKEND_ONLY" -ne 1 ]; then if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend build_frontend
build_public_website
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding ensure_workflow_ui_for_embedding
fi fi
+86 -25
View File
@@ -13,6 +13,7 @@ use tower::service_fn;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web"); static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
mod admin; mod admin;
mod auth; mod auth;
@@ -82,28 +83,28 @@ pub fn build_router(state: AppState) -> Router {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
{ {
Some(web_dir) => { Some(web_dir) => {
let web_dir_owned = web_dir.clone(); let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "dist/website".to_string());
let static_service = ServeDir::new(&web_dir) let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true) .append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| { .fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone(); let web_dir = web_dir.clone();
let website_dir = website_dir.clone();
async move { async move {
let path = req.uri().path(); let path = req.uri().path();
if is_api_path(path) { if is_api_path(path) {
return Ok::<_, Infallible>(api_not_found_response(path)); return Ok::<_, Infallible>(api_not_found_response(path));
} }
let index_path = format!("{}/index.html", web_dir); Ok::<_, Infallible>(filesystem_static_response(
match tokio::fs::read_to_string(&index_path).await { path,
Ok(html) => Ok::<_, Infallible>(html_response(html)), &web_dir,
Err(_) => Ok::<_, Infallible>( &website_dir,
Response::builder() )
.status(StatusCode::NOT_FOUND) .await)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
),
}
} }
})); }));
router.fallback_service(static_service) router.fallback_service(static_service)
@@ -122,6 +123,49 @@ fn api_not_found_response(path: &str) -> Response {
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()) .unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
} }
async fn filesystem_static_response(path: &str, web_dir: &str, website_dir: &str) -> Response {
let root_dir = if should_serve_website(path) {
website_dir
} else {
web_dir
};
let asset_path = embedded_asset_path(path);
let candidate = format!("{}/{}", root_dir.trim_end_matches('/'), asset_path);
match tokio::fs::read(&candidate).await {
Ok(bytes) => return embedded_file_response(&asset_path, &bytes),
Err(_) => {
if candidate.ends_with("/index.html") {
let without_index = candidate.trim_end_matches("index.html");
if let Ok(bytes) = tokio::fs::read(without_index).await {
return embedded_file_response(&asset_path, &bytes);
}
}
}
}
let index_path = format!("{}/index.html", root_dir.trim_end_matches('/'));
match tokio::fs::read(&index_path).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) if should_serve_website(path) => {
let fallback_index = format!("{}/index.html", web_dir.trim_end_matches('/'));
match tokio::fs::read(&fallback_index).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) => not_found_response(path),
}
}
Err(_) => not_found_response(path),
}
}
fn not_found_response(path: &str) -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
async fn embedded_static_handler(req: Request<Body>) -> Response { async fn embedded_static_handler(req: Request<Body>) -> Response {
let path = req.uri().path(); let path = req.uri().path();
if is_api_path(path) { if is_api_path(path) {
@@ -129,23 +173,48 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
} }
let asset_path = embedded_asset_path(path); let asset_path = embedded_asset_path(path);
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) { let static_dir = if should_serve_website(path) {
&EMBEDDED_WEBSITE
} else {
&EMBEDDED_WEB
};
if let Some(file) = static_dir.get_file(&asset_path) {
return embedded_file_response(&asset_path, file.contents()); return embedded_file_response(&asset_path, file.contents());
} }
if let Some(file) = EMBEDDED_WEB.get_file("index.html") { if let Some(file) = static_dir.get_file("index.html") {
return embedded_file_response("index.html", file.contents()); return embedded_file_response("index.html", file.contents());
} }
if should_serve_website(path) && EMBEDDED_WEBSITE.get_file("index.html").is_none() {
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
return embedded_file_response("index.html", file.contents());
}
}
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("embedded dist/web/index.html not found")) .body(Body::from("embedded frontend index.html not found"))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()) .unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
} }
fn is_api_path(path: &str) -> bool { fn is_api_path(path: &str) -> bool {
path.starts_with("/api/") || path.starts_with("/v1/") path.starts_with("/api/")
|| path.starts_with("/v1/")
|| path == "/health"
|| path == "/ready"
|| path == "/metrics"
}
fn should_serve_website(path: &str) -> bool {
path == "/"
|| path.starts_with("/_next/")
|| path == "/favicon.ico"
|| path == "/favicon.png"
|| path == "/favicon-64.png"
|| path == "/logo.png"
} }
fn embedded_asset_path(path: &str) -> String { fn embedded_asset_path(path: &str) -> String {
@@ -157,14 +226,6 @@ fn embedded_asset_path(path: &str) -> String {
} }
} }
fn html_response(html: String) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response { fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
let content_type = mime_guess::from_path(path) let content_type = mime_guess::from_path(path)
.first_or_octet_stream() .first_or_octet_stream()
+9 -9
View File
@@ -378,7 +378,7 @@ export const sidebarGroups = [
{ {
label: '控制台', label: '控制台',
items: [ items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> }, { to: '/dashboard', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> }, { to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> }, { to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
@@ -430,7 +430,7 @@ export const userSidebarGroups = [
}, },
]; ];
const topNavItems = [ const topNavItems = [
{ label: '控制台', to: '/' }, { label: '控制台', to: '/dashboard' },
{ label: '模型广场', to: '/models' }, { label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' }, { label: '生图助手', to: '/picture-agent' },
{ label: '短视频工厂', to: '/money-printer' }, { label: '短视频工厂', to: '/money-printer' },
@@ -700,11 +700,11 @@ function AppShell() {
if (isUserPage && isPublicUserPage) { if (isUserPage && isPublicUserPage) {
if (userAuthed) return <Navigate to="/user/dashboard" />; if (userAuthed) return <Navigate to="/user/dashboard" />;
if (authed) return <Navigate to="/" />; if (authed) return <Navigate to="/dashboard" />;
} }
if (isAdminLoginPage) { if (isAdminLoginPage) {
if (authed) return <Navigate to="/" />; if (authed) return <Navigate to="/dashboard" />;
return ( return (
<AdminLoginPage <AdminLoginPage
t={t} t={t}
@@ -911,7 +911,7 @@ function AppShell() {
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'} end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
onClick={() => setDrawerOpen(false)} onClick={() => setDrawerOpen(false)}
> >
@@ -923,7 +923,7 @@ function AppShell() {
))} ))}
<div className="mobile-nav-group"> <div className="mobile-nav-group">
<div className="mobile-nav-label">{t('更多')}</div> <div className="mobile-nav-label">{t('更多')}</div>
{currentTopNavItems.filter((n) => n.to !== '/' && n.to !== '/user/dashboard').map((item) => ( {currentTopNavItems.filter((n) => n.to !== '/dashboard' && n.to !== '/user/dashboard').map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -945,7 +945,7 @@ function AppShell() {
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'} end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined} data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
aria-label={sidebarCollapsed ? t(item.label) : undefined} aria-label={sidebarCollapsed ? t(item.label) : undefined}
@@ -968,7 +968,7 @@ function AppShell() {
<PageTransition> <PageTransition>
<Suspense fallback={<RouteLoadingFallback />}> <Suspense fallback={<RouteLoadingFallback />}>
<Routes> <Routes>
<Route path="/" element={<Dashboard adminName={displayName} />} /> <Route path="/dashboard" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} /> <Route path="/sites" element={<Sites />} />
<Route path="/site-announcements" element={<SiteAnnouncements />} /> <Route path="/site-announcements" element={<SiteAnnouncements />} />
<Route path="/accounts" element={<Accounts />} /> <Route path="/accounts" element={<Accounts />} />
@@ -1013,7 +1013,7 @@ function AppShell() {
<Route path="/user/workspace" element={<Workspace />} /> <Route path="/user/workspace" element={<Workspace />} />
<Route path="/user/about" element={<About />} /> <Route path="/user/about" element={<About />} />
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} /> <Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/dashboard"} />} />
</Routes> </Routes>
</Suspense> </Suspense>
</PageTransition> </PageTransition>
+4 -19
View File
@@ -1,29 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const backendUrl = process.env.BOSSAI_BACKEND_URL || "http://127.0.0.1:4000";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "export",
poweredByHeader: false, poweredByHeader: false,
images: {
unoptimized: true,
},
turbopack: { turbopack: {
root: __dirname, root: __dirname,
}, },
async rewrites() {
return [
{ source: "/boos", destination: `${backendUrl}/boos` },
{ source: "/user/:path*", destination: `${backendUrl}/user/:path*` },
{ source: "/api/:path*", destination: `${backendUrl}/api/:path*` },
{ source: "/v1/:path*", destination: `${backendUrl}/v1/:path*` },
{ source: "/assets/:path*", destination: `${backendUrl}/assets/:path*` },
{ source: "/workflow-ui/:path*", destination: `${backendUrl}/workflow-ui/:path*` },
{ source: "/comfyui/:path*", destination: `${backendUrl}/comfyui/:path*` },
{ source: "/monitor-proxy/:path*", destination: `${backendUrl}/monitor-proxy/:path*` },
{ source: "/oauth/:path*", destination: `${backendUrl}/oauth/:path*` },
{ source: "/health", destination: `${backendUrl}/health` },
{ source: "/ready", destination: `${backendUrl}/ready` },
{ source: "/metrics", destination: `${backendUrl}/metrics` },
];
},
}; };
export default nextConfig; export default nextConfig;