Deploy admin web with release bundle
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-01 17:56:16 +08:00
parent 8718472dbd
commit 443a7781e5
9 changed files with 189 additions and 51 deletions

View File

@@ -5,8 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build", "build": "node ../../scripts/admin-web-build.mjs build",
"typecheck": "tsc --noEmit", "typecheck": "node ../../scripts/admin-web-build.mjs typecheck",
"preview": "vite preview --host 127.0.0.1" "preview": "vite preview --host 127.0.0.1"
}, },
"dependencies": { "dependencies": {

View File

@@ -12,6 +12,7 @@
2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server` 2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server`
3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理、注册邀请码管理。 3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理、注册邀请码管理。
4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。 4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。
5. 发布包内由 Web 网关把独立后台前端挂到同域 `/admin/`Rust `api-server` 自身仍不恢复旧的 `GET /admin` 内嵌页面。
## 2. 用户与使用场景 ## 2. 用户与使用场景
@@ -66,7 +67,7 @@
3. 不新增 SpacetimeDB 表结构。 3. 不新增 SpacetimeDB 表结构。
4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。 4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。
5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。 5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。
6. 不保留 `GET /admin` 同源内嵌页面作为正式后台入口。 6. 不保留 Rust `api-server` `GET /admin` 同源内嵌页面作为正式后台入口;部署态 `/admin/` 只允许由独立后台前端静态产物承接
## 4. 页面与交互要求 ## 4. 页面与交互要求
@@ -144,7 +145,7 @@ API 调试页是受控接口调试台,不是通用代理工具:
5. API 调试页可通过后端调试接口访问 `/healthz` 5. API 调试页可通过后端调试接口访问 `/healthz`
6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。 6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。
7. 邀请码管理页可创建/更新注册邀请码,并展示返回记录。 7. 邀请码管理页可创建/更新注册邀请码,并展示返回记录。
8. `GET /admin` 保持 404不恢复旧内嵌页面。 8. 直连 Rust `api-server` `GET /admin` 保持 404不恢复旧内嵌页面;通过发布包 Web 网关访问 `/admin/` 时返回独立后台前端
9. `npm run check:encoding` 通过。 9. `npm run check:encoding` 通过。
## 7. 首版任务拆解 ## 7. 首版任务拆解

View File

@@ -4,13 +4,13 @@
对应 PRD[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md) 对应 PRD[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。 落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。`2026-05-01` 起,根构建与 Ubuntu 发布包会同步构建后台前端,并在发布包 Web 网关中以同域 `/admin/` 暴露。
## 1. 结论 ## 1. 结论
后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server` 后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接 本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,Rust `api-server` 直连时`GET /admin` 不再挂载。部署态后台入口由发布包内 `web-server.mjs` 承接:`/admin/` 返回独立前端静态产物,`/admin/api/*` 继续反代到 `api-server`
## 2. 工程结构 ## 2. 工程结构
@@ -384,12 +384,14 @@ export interface ProfileInviteCodeAdminResponse {
### 7.2 构建部署 ### 7.2 构建部署
首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择 当前发布形态固定为同域 `/admin/`
1. 独立静态站点域名,例如 `https://admin.example.com` 1. 本地单独执行 `npm run admin-web:build` 时,后台构建产物默认输出到 `apps/admin-web/dist`
2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标 2. 根工程执行 `npm run build` 时,会先构建主前端,再构建后台前端;任一构建失败或输出 warning 都会让构建门禁失败
3. Ubuntu 发布包执行 `npm run deploy:rust:remote` 时,后台前端以 Vite `--base /admin/` 构建到发布包 `web/admin/`
4. 发布包 `web-server.mjs``/admin` 返回 301 到 `/admin/`,对 `/admin/``/admin/*` 提供后台 SPA fallback`/admin/api/*` 优先反代到 `api-server`
无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。 该形态不新增后台静态端口和后台专用后端。`server-rs` 仍然是唯一管理 API 后端,后台前端不直连 SpacetimeDB
### 7.3 后台工程脚本 ### 7.3 后台工程脚本
@@ -399,8 +401,8 @@ export interface ProfileInviteCodeAdminResponse {
{ {
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build", "build": "node ../../scripts/admin-web-build.mjs build",
"typecheck": "tsc --noEmit", "typecheck": "node ../../scripts/admin-web-build.mjs typecheck",
"preview": "vite preview --host 127.0.0.1" "preview": "vite preview --host 127.0.0.1"
} }
} }
@@ -408,6 +410,8 @@ export interface ProfileInviteCodeAdminResponse {
如果后续接入根 npm workspace再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。 如果后续接入根 npm workspace再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。
当前工程没有启用 npm workspace因此后台构建脚本必须从仓库根目录调用 root toolchain。`scripts/admin-web-build.mjs` 统一执行 `tsc --noEmit -p apps/admin-web/tsconfig.json` 与 Vite 构建,避免 `npm --prefix apps/admin-web` 在子目录找不到 `tsc`
当前根工程同步提供以下转发脚本: 当前根工程同步提供以下转发脚本:
1. `npm run admin-web:dev` 1. `npm run admin-web:dev`

View File

@@ -137,11 +137,11 @@ npm run deploy:rust:remote
1. 在仓库根目录创建 `build/` 1. 在仓库根目录创建 `build/`
2.`build/` 下创建当前时间命名的目标目录,例如 `build/20260422-153000/` 2.`build/` 下创建当前时间命名的目标目录,例如 `build/20260422-153000/`
3. 使用 Vite 构建前端 release 到目标目录的 `web/` 3. 使用 Vite 构建前端 release 到目标目录的 `web/`,并构建后台管理前端 release 到 `web/admin/`;后台构建固定使用 `/admin/` 作为 Vite base
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。 4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。 5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。 6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*``/generated-*``/healthz` 反代到本包内的 `api-server` 7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` `web/admin/`;其中 `/admin` 跳转到 `/admin/``/admin/` 提供后台 SPA`/admin/api/*``/api/*``/generated-*``/healthz` 反代到本包内的 `api-server`
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env``.env.local`,兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-host``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,其中 Web 默认只监听 `127.0.0.1`;并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。 8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env``.env.local`,兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-host``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,其中 Web 默认只监听 `127.0.0.1`;并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
@@ -157,7 +157,9 @@ build/<timestamp>/
├─ .env.local ├─ .env.local
├─ web/ ├─ web/
│ ├─ .env │ ├─ .env
─ .env.local ─ .env.local
│ └─ admin/
│ └─ index.html
├─ api-server ├─ api-server
├─ spacetime_module.wasm ├─ spacetime_module.wasm
├─ migration-bootstrap-secret.txt ├─ migration-bootstrap-secret.txt
@@ -178,6 +180,8 @@ npm run build:rust:ubuntu -- --database genarrative-dev --web-host 127.0.0.1 --w
npm run build:rust:ubuntu -- --skip-upload npm run build:rust:ubuntu -- --skip-upload
``` ```
`--skip-web-build` 会同时跳过主前端和后台管理前端构建,仅用于调试已有发布包内容;正式发布不应使用该参数。
目标服务器启动: 目标服务器启动:
```bash ```bash
@@ -186,7 +190,7 @@ cd build/<timestamp>
./stop.sh ./stop.sh
``` ```
如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/``api-server``spacetime_module.wasm``migration-bootstrap-secret.txt``scripts/``.env*``start.sh``stop.sh``web-server.mjs``README.md` 等发布产物;文件产物使用普通复制,`web/``scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/``logs/``run/``deploy-state/``database-migrations/` 这类运行态目录。 如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/``api-server``spacetime_module.wasm``migration-bootstrap-secret.txt``scripts/``.env*``start.sh``stop.sh``web-server.mjs``README.md` 等发布产物;后台管理前端位于 `web/admin/`,随 `web/` 一并覆盖。文件产物使用普通复制,`web/``scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/``logs/``run/``deploy-state/``database-migrations/` 这类运行态目录。
安全边界: 安全边界:

View File

@@ -9,8 +9,8 @@
"dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"dev:web": "node scripts/dev-web-rust.mjs", "dev:web": "node scripts/dev-web-rust.mjs",
"admin-web:dev": "npm --prefix apps/admin-web run dev --", "admin-web:dev": "npm --prefix apps/admin-web run dev --",
"admin-web:build": "npm --prefix apps/admin-web run build --", "admin-web:build": "node scripts/admin-web-build.mjs build",
"admin-web:typecheck": "npm --prefix apps/admin-web run typecheck --", "admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
"admin-web:preview": "npm --prefix apps/admin-web run preview --", "admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh", "spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh",
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",

View File

@@ -0,0 +1,73 @@
import {spawnSync} from 'node:child_process';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '..');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const adminTsconfigPath = resolve(adminWebDir, 'tsconfig.json');
const adminViteConfigPath = resolve(adminWebDir, 'vite.config.ts');
const tscBinPath = resolve(repoRoot, 'node_modules/typescript/bin/tsc');
const viteCliPath = resolve(scriptDir, 'vite-cli.mjs');
const command = process.argv[2] ?? 'build';
const extraArgs = process.argv.slice(3);
function usage() {
console.error('用法: node scripts/admin-web-build.mjs <typecheck|build> [vite-build-args...]');
}
function runNodeScript(label, args) {
console.log(`[admin-web] ${label}`);
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.error) {
console.error(`[admin-web] ${label} failed to start: ${result.error.message}`);
process.exit(1);
}
if (result.signal) {
console.error(`[admin-web] ${label} was terminated by signal ${result.signal}`);
process.exit(1);
}
if ((result.status ?? 0) !== 0) {
process.exit(result.status ?? 1);
}
}
function runTypecheck() {
runNodeScript('typecheck', [
tscBinPath,
'--noEmit',
'-p',
adminTsconfigPath,
]);
}
if (command === 'typecheck') {
runTypecheck();
} else if (command === 'build') {
runTypecheck();
runNodeScript('vite build', [
viteCliPath,
'build',
'--config',
adminViteConfigPath,
...extraArgs,
]);
} else {
usage();
process.exit(1);
}

View File

@@ -2,39 +2,60 @@ import {spawnSync} from 'node:child_process';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url)); const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
const args = [viteCliPath, 'build', ...process.argv.slice(2)]; const adminWebBuildPath = fileURLToPath(new URL('./admin-web-build.mjs', import.meta.url));
const forwardedArgs = process.argv.slice(2);
const result = spawnSync(process.execPath, args, { const results = [
cwd: process.cwd(), runBuildStep('web', [viteCliPath, 'build', ...forwardedArgs]),
encoding: 'utf8', runBuildStep('admin-web', [adminWebBuildPath, 'build']),
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if ((result.status ?? 0) !== 0) {
process.exit(result.status ?? 1);
}
const warningPattern = /\bwarn(?:ing)?\b/i;
const ignoredWarningPatterns = [
/ExperimentalWarning/u,
]; ];
const warningLines = `${result.stdout ?? ''}\n${result.stderr ?? ''}` const failedResult = results.find(result => result.error || result.signal || (result.status ?? 0) !== 0);
.split(/\r?\n/u) if (failedResult) {
.map(line => line.trim()) if (failedResult.error) {
.filter(line => line.length > 0) console.error(`Build gate failed to start a build step: ${failedResult.error.message}`);
.filter(line => warningPattern.test(line)) } else if (failedResult.signal) {
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line))); console.error(`Build gate step was terminated by signal ${failedResult.signal}`);
}
process.exit(failedResult.status ?? 1);
}
const warningLines = results.flatMap((result) => collectWarningLines(result));
if (warningLines.length > 0) { if (warningLines.length > 0) {
console.error('Build gate failed because warnings were emitted:'); console.error('Build gate failed because warnings were emitted:');
[...new Set(warningLines)].forEach(line => console.error(`- ${line}`)); [...new Set(warningLines)].forEach(line => console.error(`- ${line}`));
process.exit(1); process.exit(1);
} }
function runBuildStep(label, args) {
console.log(`[build-gate] ${label}`);
const result = spawnSync(process.execPath, args, {
cwd: process.cwd(),
encoding: 'utf8',
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
return result;
}
function collectWarningLines(result) {
const warningPattern = /\bwarn(?:ing)?\b/i;
const ignoredWarningPatterns = [
/ExperimentalWarning/u,
];
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`
.split(/\r?\n/u)
.map(line => line.trim())
.filter(line => line.length > 0)
.filter(line => warningPattern.test(line))
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
}

View File

@@ -310,6 +310,7 @@ fi
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}" TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
WEB_DIR="${TARGET_DIR}/web" WEB_DIR="${TARGET_DIR}/web"
ADMIN_WEB_DIR="${WEB_DIR}/admin"
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server" API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm" WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
@@ -364,6 +365,12 @@ if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
cd "${REPO_ROOT}" cd "${REPO_ROOT}"
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
) )
echo "[deploy:rust] 构建后台 Vite release -> ${ADMIN_WEB_DIR}"
(
cd "${REPO_ROOT}"
MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir
)
fi fi
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
@@ -421,11 +428,14 @@ import {fileURLToPath} from 'node:url';
const releaseDir = path.dirname(fileURLToPath(import.meta.url)); const releaseDir = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.join(releaseDir, 'web'); const webRoot = path.join(releaseDir, 'web');
const adminWebRoot = path.join(webRoot, 'admin');
const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1'; const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1';
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000'); const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082'); const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
const indexPath = path.join(webRoot, 'index.html'); const indexPath = path.join(webRoot, 'index.html');
const adminIndexPath = path.join(adminWebRoot, 'index.html');
const proxyPrefixes = [ const proxyPrefixes = [
'/admin/api',
'/api/', '/api/',
'/api', '/api',
'/generated-character-drafts', '/generated-character-drafts',
@@ -466,11 +476,11 @@ function sendFile(response, filePath) {
.pipe(response); .pipe(response);
} }
function serveStatic(request, response, pathname) { function serveStaticFromRoot(response, pathname, rootDir, fallbackIndexPath) {
const decodedPath = decodeURIComponent(pathname); const decodedPath = decodeURIComponent(pathname);
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath; const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
const filePath = path.normalize(path.join(webRoot, relativePath)); const filePath = path.normalize(path.join(rootDir, relativePath));
const safeRelativePath = path.relative(webRoot, filePath); const safeRelativePath = path.relative(rootDir, filePath);
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) { if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'}); response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
@@ -480,12 +490,21 @@ function serveStatic(request, response, pathname) {
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath ? filePath
: indexPath; : fallbackIndexPath;
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)}); response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
sendFile(response, resolvedFilePath); sendFile(response, resolvedFilePath);
} }
function serveStatic(request, response, pathname) {
serveStaticFromRoot(response, pathname, webRoot, indexPath);
}
function serveAdminStatic(response, pathname) {
const adminPath = pathname === '/admin/' ? '/' : pathname.replace(/^\/admin/u, '');
serveStaticFromRoot(response, adminPath, adminWebRoot, adminIndexPath);
}
function proxyToApi(request, response) { function proxyToApi(request, response) {
const targetUrl = new URL(request.url || '/', apiTarget); const targetUrl = new URL(request.url || '/', apiTarget);
const proxyRequest = http.request( const proxyRequest = http.request(
@@ -522,6 +541,17 @@ const server = http.createServer((request, response) => {
return; return;
} }
if (url.pathname === '/admin') {
response.writeHead(301, {location: '/admin/'});
response.end();
return;
}
if (url.pathname === '/admin/' || url.pathname.startsWith('/admin/')) {
serveAdminStatic(response, url.pathname);
return;
}
serveStatic(request, response, url.pathname); serveStatic(request, response, url.pathname);
}); });
@@ -1189,7 +1219,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
## 内容 ## 内容
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\` - \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
- \`web/\`Vite release 静态资源 - \`web/\`主前端 Vite release 静态资源\`web/admin/\` 为后台管理前端静态资源
- \`api-server\`x86_64-unknown-linux-gnu release 可执行文件 - \`api-server\`x86_64-unknown-linux-gnu release 可执行文件
- \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块 - \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块
- \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除 - \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
@@ -1211,6 +1241,11 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations/<database>/\`,随后清库发布新 wasm并用 \`--replace-existing\` 导入回灌。 默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations/<database>/\`,随后清库发布新 wasm并用 \`--replace-existing\` 导入回灌。
## 入口
- 主站:\`http://<web-host>:<web-port>/\`
- 后台:\`http://<web-host>:<web-port>/admin/\`
## 环境变量 ## 环境变量
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。 - 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。

View File

@@ -10,7 +10,7 @@ usage() {
说明: 说明:
1. 如果部署目录已有旧版本且存在 stop.sh则先执行旧版本 stop.sh。 1. 如果部署目录已有旧版本且存在 stop.sh则先执行旧版本 stop.sh。
2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。 2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。
3. 把指定发布目录中的白名单产物复制覆盖到部署目录。 3. 把指定发布目录中的白名单产物复制覆盖到部署目录,后台前端随 web/admin/ 一并覆盖
4. 如指定 --clear-database则以清库模式执行新版本 start.sh。 4. 如指定 --clear-database则以清库模式执行新版本 start.sh。
5. 默认允许新版本 start.sh 在 schema 冲突时自动导出、清库发布、导入回灌。 5. 默认允许新版本 start.sh 在 schema 冲突时自动导出、清库发布、导入回灌。
6. 最后执行新版本 start.sh。 6. 最后执行新版本 start.sh。