feat: serve website at root path
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
data/
|
||||
node_modules/
|
||||
website/.next/
|
||||
website/out/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
|
||||
@@ -401,7 +401,7 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
||||
|
||||
> **运行时零 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
|
||||
|
||||
# 本地开发
|
||||
BOSSAI_BACKEND_URL=http://127.0.0.1:4000 npm run website:dev
|
||||
# 本地开发官网
|
||||
npm run website:dev
|
||||
|
||||
# 类型检查与生产构建
|
||||
npm run website:typecheck
|
||||
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 服务处理。
|
||||
|
||||
### 生产单二进制构建
|
||||
|
||||
|
||||
@@ -282,6 +282,55 @@ build_frontend() {
|
||||
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() {
|
||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||
log "跳过 Workflow UI 构建检查(--skip-frontend)"
|
||||
@@ -374,12 +423,14 @@ cleanup_started_server() {
|
||||
|
||||
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
log "跳过启动(--frontend-only)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||
ensure_workflow_ui_for_embedding
|
||||
fi
|
||||
|
||||
+85
-24
@@ -13,6 +13,7 @@ use tower::service_fn;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
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 auth;
|
||||
@@ -82,28 +83,28 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
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)
|
||||
.append_index_html_on_directories(true)
|
||||
.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 {
|
||||
let path = req.uri().path();
|
||||
if is_api_path(path) {
|
||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||
}
|
||||
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => Ok::<_, Infallible>(html_response(html)),
|
||||
Err(_) => Ok::<_, Infallible>(
|
||||
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()),
|
||||
),
|
||||
}
|
||||
Ok::<_, Infallible>(filesystem_static_response(
|
||||
path,
|
||||
&web_dir,
|
||||
&website_dir,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
}));
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
let path = req.uri().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);
|
||||
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());
|
||||
}
|
||||
|
||||
if let Some(file) = static_dir.get_file("index.html") {
|
||||
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()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.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())
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -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 {
|
||||
let content_type = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
|
||||
+9
-9
@@ -378,7 +378,7 @@ export const sidebarGroups = [
|
||||
{
|
||||
label: '控制台',
|
||||
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: '/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> },
|
||||
@@ -430,7 +430,7 @@ export const userSidebarGroups = [
|
||||
},
|
||||
];
|
||||
const topNavItems = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '控制台', to: '/dashboard' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
{ label: '生图助手', to: '/picture-agent' },
|
||||
{ label: '短视频工厂', to: '/money-printer' },
|
||||
@@ -700,11 +700,11 @@ function AppShell() {
|
||||
|
||||
if (isUserPage && isPublicUserPage) {
|
||||
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
||||
if (authed) return <Navigate to="/" />;
|
||||
if (authed) return <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
if (isAdminLoginPage) {
|
||||
if (authed) return <Navigate to="/" />;
|
||||
if (authed) return <Navigate to="/dashboard" />;
|
||||
return (
|
||||
<AdminLoginPage
|
||||
t={t}
|
||||
@@ -911,7 +911,7 @@ function AppShell() {
|
||||
<NavLink
|
||||
key={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' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
@@ -923,7 +923,7 @@ function AppShell() {
|
||||
))}
|
||||
<div className="mobile-nav-group">
|
||||
<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
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -945,7 +945,7 @@ function AppShell() {
|
||||
<NavLink
|
||||
key={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' : ''}`}
|
||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||
@@ -968,7 +968,7 @@ function AppShell() {
|
||||
<PageTransition>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
||||
<Route path="/dashboard" element={<Dashboard adminName={displayName} />} />
|
||||
<Route path="/sites" element={<Sites />} />
|
||||
<Route path="/site-announcements" element={<SiteAnnouncements />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
@@ -1013,7 +1013,7 @@ function AppShell() {
|
||||
<Route path="/user/workspace" element={<Workspace />} />
|
||||
<Route path="/user/about" element={<About />} />
|
||||
|
||||
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} />
|
||||
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/dashboard"} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PageTransition>
|
||||
|
||||
+4
-19
@@ -1,29 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const backendUrl = process.env.BOSSAI_BACKEND_URL || "http://127.0.0.1:4000";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
output: "export",
|
||||
poweredByHeader: false,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
turbopack: {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user