From f74717c415302187c21d46afc9bec217676fe861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Sat, 9 May 2026 21:49:25 +0800 Subject: [PATCH] fix(dev): resolve local stack ports before startup --- .hermes/shared-memory/pitfalls.md | 4 +- .../SKILL.md | 127 ++++++++++++++ AGENTS.md | 1 + ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 37 ++-- scripts/dev-rust-stack.sh | 59 ++++--- scripts/dev-stack-port-utils.mjs | 164 ++++++++++++++++++ scripts/dev-stack-port-utils.test.ts | 51 ++++++ scripts/dev-web-rust.mjs | 22 ++- 8 files changed, 428 insertions(+), 37 deletions(-) create mode 100644 .hermes/skills/genarrative-dev-stack-port-routing/SKILL.md create mode 100644 scripts/dev-stack-port-utils.mjs create mode 100644 scripts/dev-stack-port-utils.test.ts diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 733dea4c..0ae4aa1f 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -111,8 +111,8 @@ - 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。 - 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。 -- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。确认需要新启动 SpacetimeDB 时,脚本先检测 `3101`,被占用则输出占用进程并选择最近可用端口,保证 publish 与 `api-server` 都连接同一个实际 SpacetimeDB URL。`api-server` 启动前也会检测 `8082` 并选择最近可用端口。Windows / Git Bash 下不要用 `tr/head/xargs` 管道读取 `spacetime.pid` 或 URL 记录,脚本应使用 Node 读取并短重试,避免 `tr: read error: Device or resource busy`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。 -- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`3101` 或 `8082` 被其他进程占用时,脚本输出占用进程并使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。 +- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。确认需要新启动 SpacetimeDB 时,脚本先检测 `3101`,被占用则输出占用进程并选择最近可用端口,保证 publish 与 `api-server` 都连接同一个实际 SpacetimeDB URL。`api-server` 启动前也会检测 `8082` 并选择最近可用端口。Windows / Git Bash 下不要用 `tr/head/xargs` 管道读取 `spacetime.pid` 或 URL 记录,脚本应使用 Node 读取并短重试,避免 `tr: read error: Device or resource busy`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。`npm run dev` 会在启动前检查 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,不可用时自动寻找后续可用端口,并把实际端口传给 publish、后端环境变量和前端代理目标。 +- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`3101` 或 `8082` 被其他进程占用时,脚本输出占用进程并使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。端口漂移时控制台会打印 `[dev:ports] ... 不可用,改用 ...`,后续 `[dev:rust] web/admin web/rust api/spacetime` 地址应与实际端口一致。 - 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。 ## 本地 SpacetimeDB publish 401 可清本地库重发 diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md new file mode 100644 index 00000000..6abc4d55 --- /dev/null +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -0,0 +1,127 @@ +--- +name: genarrative-dev-stack-port-routing +short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。 +description: 在 Genarrative 中修改 npm run dev / npm run dev:rust / npm run dev:web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [Genarrative, dev-stack, 端口探测, Vite, api-server, SpacetimeDB, npm-run-dev] + related_skills: [genarrative-admin-backoffice] +--- + +# Genarrative 本地 dev 启动端口与代理目标串联流程 + +用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口检查、端口漂移和后续流程目标传递。 + +## 适用场景 + +- 修改 `scripts/dev-rust-stack.sh`、`scripts/dev-web-rust.mjs`、`scripts/dev-stack-port-utils.mjs`。 +- 处理 `3000`、`3101`、`3102`、`8082` 等端口被占用导致本地开发栈启动失败。 +- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。 +- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。 +- 修改本地联调文档或 `.hermes/shared-memory/pitfalls.md` 中的 dev 启动口径。 + +## 当前端口职责 + +默认优先端口: + +1. 主站 Vite:`3000`,对浏览器通常展示为 `http://127.0.0.1:/`。 +2. Rust `api-server`:`8082`,健康检查为 `http://127.0.0.1:/healthz`。 +3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:/v1/ping`。 +4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:/admin/`。 + +端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。 + +## 实现入口 + +- `package.json` + - `dev` 和 `dev:rust`:执行 `node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh`。 + - `dev:web`:执行 `node scripts/dev-web-rust.mjs`。 +- `scripts/dev-stack-port-utils.mjs` + - `isPortAvailable(...)`:探测端口是否可监听。 + - `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。 + - `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。 + - CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`。 +- `scripts/dev-rust-stack.sh` + - 解析 CLI 参数后,先计算 `API_TARGET_HOST` 与 `ADMIN_WEB_TARGET_HOST`。 + - 调用 `resolve_dev_stack_ports` 覆盖 `SPACETIME_PORT`、`API_PORT`、`WEB_PORT`、`ADMIN_WEB_PORT`。 + - 再构造 `SPACETIME_SERVER` 和 `RUST_SERVER_TARGET`。 +- `scripts/dev-web-rust.mjs` + - 单独启动主站前端时,也先用 `findAvailablePort` 检查 `WEB_PORT` / 默认 `3000`。 + +## 必须保持的传递链路 + +`npm run dev` / `npm run dev:rust` 中端口解析后,必须同步到以下位置: + +1. SpacetimeDB 启动:`spacetime start --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"`。 +2. SpacetimeDB 发布:`spacetime publish ... --server "${SPACETIME_SERVER}"`。 +3. Rust api-server:`GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`。 +4. api-server 健康检查:`wait_for_api_server "${RUST_SERVER_TARGET}/healthz" ...`。 +5. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。 +6. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 +7. 控制台日志:`[dev:ports]` 和 `[dev:rust] web/admin web/rust api/spacetime` 必须显示最终实际地址。 + +如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。 + +## 修改流程 + +1. 先读当前脚本和文档: + - `scripts/dev-stack-port-utils.mjs` + - `scripts/dev-rust-stack.sh` + - `scripts/dev-web-rust.mjs` + - `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md` + - `.hermes/shared-memory/pitfalls.md` +2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。 +3. 对 Bash 脚本只做局部补丁,避免整文件重写导致中文注释或换行大面积变化。 +4. 修改 `dev-rust-stack.sh` 时确认变量顺序: + - 先有 `REPO_ROOT`。 + - 再计算 `API_TARGET_HOST` / `ADMIN_WEB_TARGET_HOST`。 + - 再调用端口解析工具。 + - 再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`。 +5. 修改 `dev:web` 时不要自动改后端目标策略;`dev:web` 只负责主站 Vite 端口可用性与已有后端目标选择。 +6. 同步更新技术文档和团队共享记忆。 + +## 测试与验证 + +最小验证: + +```bash +bash -n scripts/dev-rust-stack.sh +npm run test -- scripts/dev-stack-port-utils.test.ts +npm run check:encoding +node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0 +``` + +端口冲突回归测试建议: + +1. 用测试或临时 Node server 占用某个优先端口。 +2. 调用 `findAvailablePort`,断言结果大于被占用端口。 +3. 调用 `resolveDevStackPorts`,断言四个结果互不相同。 +4. 如果实际启动完整栈,观察控制台: + - `[dev:ports] ... 不可用,改用 ...` + - `[dev:rust] rust api: http://...:` + - `[dev:rust] spacetime: http://...:` + - 主站和后台 Vite 启动端口与日志一致。 + +完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 `/healthz`、`/v1/ping` 和页面端口;不要等待 `npm run dev` 自然退出。 + +## 常见坑 + +1. **只让 Vite 自己漂移端口。** 这样终端可能出现可访问前端,但脚本和文档仍认为是 `3000`,后台目标或日志会错。 +2. **只改 SpacetimeDB start,不改 publish。** standalone 可能监听新端口,但 publish 仍连旧 `3101`。 +3. **只改 `GENARRATIVE_API_PORT`,不改 `RUST_SERVER_TARGET`。** api-server 已在新端口监听,但 Vite 代理仍打旧端口。 +4. **使用 `0.0.0.0` 作为浏览器访问地址。** 监听可以是 `0.0.0.0`,展示给用户和健康检查通常用 `127.0.0.1`。 +5. **端口探测和实际启动之间存在竞态。** 已经探测可用的端口仍可能被外部进程抢占;SpacetimeDB 启动后仍要解析实际监听地址,api-server 和 Vite 失败时要打印清晰日志。 +6. **运行全仓库 lint 误判。** 当前仓库可能有既有 lint 问题。验证本功能时优先运行定向测试、Bash 语法检查、编码检查,并在最终说明中区分既有 lint 失败与本次改动。 + +## 验收清单 + +- [ ] 端口工具有测试覆盖端口被占用和多端口互斥解析。 +- [ ] `dev-rust-stack.sh` 通过 `bash -n`。 +- [ ] `npm run dev` / `npm run dev:rust` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。 +- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。 +- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 +- [ ] 长期踩坑同步更新 `.hermes/shared-memory/pitfalls.md`。 +- [ ] 修改中文文件后运行 `npm run check:encoding`。 diff --git a/AGENTS.md b/AGENTS.md index 6231885b..e0a2dd76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ - [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md) - [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md) - 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。 +- 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。 - 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。 - 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。 - 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 83538794..9e8d3df5 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -25,24 +25,33 @@ npm run dev:rust 默认端口: -1. Web 前端:`http://127.0.0.1:3000` -2. Rust `api-server`:`http://127.0.0.1:8082` -3. SpacetimeDB standalone:`http://127.0.0.1:3101` -4. SpacetimeDB database:优先读取仓库根目录 `spacetime.local.json` 的 `database` 字段;没有该字段时才回退到 `genarrative-dev` -5. SpacetimeDB 本地数据与日志目录:`server-rs/.spacetimedb/local` +1. Web 前端:优先 `http://127.0.0.1:3000` +2. Rust `api-server`:优先 `http://127.0.0.1:8082` +3. SpacetimeDB standalone:优先 `http://127.0.0.1:3101` +4. 后台 Web 前端:优先 `http://127.0.0.1:3102` +5. SpacetimeDB database:优先读取仓库根目录 `spacetime.local.json` 的 `database` 字段;没有该字段时才回退到 `genarrative-dev` +6. SpacetimeDB 本地数据与日志目录:`server-rs/.spacetimedb/local` + +启动前端口处理: + +1. `npm run dev` / `npm run dev:rust` 会先检查 SpacetimeDB、Rust `api-server`、主站 Vite、后台 Vite 需要使用的端口。 +2. 如果优先端口不可用,脚本会从该端口开始向后寻找可用端口,并将解析后的端口覆盖到后续 `spacetime start`、`spacetime publish --server`、`GENARRATIVE_API_PORT`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_API_TARGET` 与 Vite 启动参数。 +3. 控制台会打印 `[dev:ports] ... 可用` 或 `[dev:ports] ... 不可用,改用 ...`,排查代理错配时以该日志和后续 `[dev:rust] web/admin web/rust api/spacetime` 实际地址为准。 +4. 单独 `npm run dev:web` 也会检查主站 Vite 端口;`WEB_PORT` 或默认 `3000` 不可用时,会自动切到后续可用端口并继续严格端口启动。 默认流程: 1. 检查 `cargo`、`node` 与 `spacetime` CLI。 -2. Windows Git Bash 下如 `server-rs/.spacetimedb/local/bin/current/spacetimedb-cli.exe` 不存在,先把本机 `spacetime` 所在安装目录的 `bin/` 与 `spacetime.exe` 同步到 `server-rs/.spacetimedb/local/`。 -3. 启动 SpacetimeDB 前先检查 `server-rs/.spacetimedb/local/data/spacetime.pid`:如果 pid 对应进程仍存在,且同目录 `dev-rust-spacetime-url` 中记录的 URL 可被 `spacetime server ping` 判定在线,则直接复用该宿主;如果 URL 记录缺失,会依次尝试从 `logs/dev-rust-spacetime-start.log` 和 `logs/spacetime-standalone.log` 中解析最近一次监听地址兜底。否则按正常流程重新启动。 -4. 如果确认需要新启动 SpacetimeDB,脚本会先检测 `127.0.0.1:3101` 是否可监听;若已占用,输出占用进程并选择从 `3101` 起向后的最近可用端口,再执行 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr <实际地址>`。启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`,后续 publish 与 `api-server` 都使用同一个实际 URL。 -5. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。 -6. 执行 `spacetime publish <本地数据库名> --server <实际 SpacetimeDB URL> --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 -7. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 -8. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 -9. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 -10. 任一子进程退出时,脚本回收其余子进程。 +2. 检查并解析本次联调需要使用的端口;端口不可用时先寻找可用端口,再把实际端口传给后续流程。 +3. Windows Git Bash 下如 `server-rs/.spacetimedb/local/bin/current/spacetimedb-cli.exe` 不存在,先把本机 `spacetime` 所在安装目录的 `bin/` 与 `spacetime.exe` 同步到 `server-rs/.spacetimedb/local/`。 +4. 启动 SpacetimeDB 前先检查 `server-rs/.spacetimedb/local/data/spacetime.pid`:如果 pid 对应进程仍存在,且同目录 `dev-rust-spacetime-url` 中记录的 URL 可被 `spacetime server ping` 判定在线,则直接复用该宿主;如果 URL 记录缺失,会依次尝试从 `logs/dev-rust-spacetime-start.log` 和 `logs/spacetime-standalone.log` 中解析最近一次监听地址兜底。否则按正常流程重新启动。 +5. 如果确认需要新启动 SpacetimeDB,脚本会先检测 `127.0.0.1:3101` 是否可监听;若已占用,输出占用进程并选择从 `3101` 起向后的最近可用端口,再执行 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr <实际地址>`。启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`,后续 publish 与 `api-server` 都使用同一个实际 URL。 +6. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。 +7. 执行 `spacetime publish <本地数据库名> --server <实际 SpacetimeDB URL> --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 +8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 +9. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 +10. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 +11. 任一子进程退出时,脚本回收其余子进程。 Vite 代理覆盖范围: diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index c686a0b9..7367e0f0 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -17,7 +17,7 @@ usage() { 说明: 1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。 - 2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。 + 2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict,在结构冲突时清理旧模块数据。 3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。 4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;需要新启动时会先检测端口并选择最近可用端口。 5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。 @@ -99,6 +99,25 @@ NODE ) } +resolve_dev_stack_ports() { + local key + local value + + while IFS='=' read -r key value; do + case "${key}" in + SPACETIME_PORT|API_PORT|WEB_PORT|ADMIN_WEB_PORT) + export "${key}=${value}" + ;; + esac + done < <( + node "${REPO_ROOT}/scripts/dev-stack-port-utils.mjs" resolve-dev-stack \ + "spacetime:${SPACETIME_HOST}:${SPACETIME_PORT}" \ + "api:${API_TARGET_HOST}:${API_PORT}" \ + "web:${WEB_HOST}:${WEB_PORT}" \ + "adminWeb:${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" + ) +} + cleanup() { local index @@ -876,8 +895,10 @@ fi SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" -RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" ADMIN_WEB_TARGET_HOST="$(resolve_client_host "${ADMIN_WEB_HOST}")" +resolve_dev_stack_ports +SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" +RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" trap cleanup EXIT INT TERM @@ -906,24 +927,22 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then exit 1 fi - SPACETIME_PORT="$(find_nearest_available_port "${SPACETIME_HOST}" "${SPACETIME_PORT}" "SpacetimeDB")" - SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" - - SPACETIME_START_LOG="$(spacetime_start_log_path "${SPACETIME_DATA_DIR}")" - mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")" - : >"${SPACETIME_START_LOG}" - echo "[dev:rust] 启动 spacetimedb" - ( - cd "${SERVER_RS_DIR}" - # 启动前已经由脚本选定端口,避免 api-server 和 SpacetimeDB 对数据库地址认知不一致。 - spacetime \ - start \ - --data-dir "${SPACETIME_DATA_DIR}" \ - --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ - --non-interactive - ) 2>&1 | tee "${SPACETIME_START_LOG}" & - PIDS+=("$!") - NAMES+=("spacetimedb") + SPACETIME_START_LOG="${SPACETIME_DATA_DIR}/logs/dev-rust-spacetime-start.log" + mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")" + : >"${SPACETIME_START_LOG}" + echo "[dev:rust] 启动 spacetimedb" + ( + cd "${SERVER_RS_DIR}" + # 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口; + # 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。 + printf '\n' | spacetime \ + start \ + --data-dir "${SPACETIME_DATA_DIR}" \ + --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ + --non-interactive + ) 2>&1 | tee "${SPACETIME_START_LOG}" & + PIDS+=("$!") + NAMES+=("spacetimedb") SPACETIME_LISTEN_ADDR="$(wait_for_spacetime_listen_addr "${SPACETIME_START_LOG}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}")" SPACETIME_PORT="$(port_from_listen_addr "${SPACETIME_LISTEN_ADDR}")" diff --git a/scripts/dev-stack-port-utils.mjs b/scripts/dev-stack-port-utils.mjs new file mode 100644 index 00000000..1a03a19d --- /dev/null +++ b/scripts/dev-stack-port-utils.mjs @@ -0,0 +1,164 @@ +import {createServer} from 'node:net'; + +function toListenHosts(host) { + if (host === '0.0.0.0') { + return ['0.0.0.0']; + } + + if (host === '::') { + return ['::']; + } + + return [host]; +} + +export function normalizePort(value, fallback) { + const port = Number.parseInt(String(value ?? ''), 10); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + return fallback; + } + + return port; +} + +export async function isPortAvailable({host, port}) { + if (port === 0) { + return true; + } + + const listenHosts = toListenHosts(host); + + for (const listenHost of listenHosts) { + const available = await new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once('error', () => resolve(false)); + server.listen({host: listenHost, port}, () => { + server.close(() => resolve(true)); + }); + }); + + if (!available) { + return false; + } + } + + return true; +} + +export async function findAvailablePort({host, preferredPort, reservedPorts = new Set(), maxAttempts = 200}) { + const startPort = normalizePort(preferredPort, 0); + + if (startPort === 0) { + return await reserveEphemeralPort(host, reservedPorts); + } + + for (let offset = 0; offset <= maxAttempts; offset += 1) { + const candidate = startPort + offset; + if (candidate > 65535) { + break; + } + + if (reservedPorts.has(candidate)) { + continue; + } + + if (await isPortAvailable({host, port: candidate})) { + return candidate; + } + } + + throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口`); +} + +async function reserveEphemeralPort(host, reservedPorts) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', reject); + server.listen({host, port: 0}, () => { + const address = server.address(); + const resolvedPort = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolve(resolvedPort)); + }); + }); + + if (typeof port === 'number' && port > 0 && !reservedPorts.has(port)) { + return port; + } + } + + throw new Error(`无法为 ${host} 分配临时可用端口`); +} + +export async function resolveDevStackPorts(config) { + const reservedPorts = new Set(); + const entries = [ + ['spacetime', config.spacetime], + ['api', config.api], + ['web', config.web], + ['adminWeb', config.adminWeb], + ].filter(([, portConfig]) => Boolean(portConfig)); + const result = {}; + + for (const [name, portConfig] of entries) { + const resolvedPort = await findAvailablePort({ + host: portConfig.host, + preferredPort: portConfig.preferredPort, + reservedPorts, + }); + reservedPorts.add(resolvedPort); + result[name] = resolvedPort; + } + + return result; +} + +export function formatPortDecision({name, host, preferredPort, resolvedPort}) { + if (preferredPort === resolvedPort && preferredPort !== 0) { + return `[dev:ports] ${name}: ${host}:${resolvedPort} 可用`; + } + + return `[dev:ports] ${name}: ${host}:${preferredPort} 不可用,改用 ${host}:${resolvedPort}`; +} + +function parseCliPortConfig(rawArgs) { + const config = {}; + + for (const rawArg of rawArgs) { + const [name, host, rawPreferredPort] = rawArg.split(':'); + if (!name || !host || rawPreferredPort == null) { + throw new Error(`端口配置参数格式错误: ${rawArg}`); + } + + config[name] = { + host, + preferredPort: normalizePort(rawPreferredPort, 0), + }; + } + + return config; +} + +function envKeyForPortName(name) { + return `${name.replace(/[A-Z]/gu, (letter) => `_${letter}`).toUpperCase()}_PORT`; +} + +if (process.argv[2] === 'resolve-dev-stack') { + const config = parseCliPortConfig(process.argv.slice(3)); + const resolvedPorts = await resolveDevStackPorts(config); + + for (const [name, resolvedPort] of Object.entries(resolvedPorts)) { + const portConfig = config[name]; + console.error( + formatPortDecision({ + name, + host: portConfig.host, + preferredPort: portConfig.preferredPort, + resolvedPort, + }), + ); + console.log(`${envKeyForPortName(name)}=${resolvedPort}`); + } +} diff --git a/scripts/dev-stack-port-utils.test.ts b/scripts/dev-stack-port-utils.test.ts new file mode 100644 index 00000000..62240f9f --- /dev/null +++ b/scripts/dev-stack-port-utils.test.ts @@ -0,0 +1,51 @@ +import {createServer} from 'node:net'; +import {describe, expect, it} from 'vitest'; +import { + findAvailablePort, + resolveDevStackPorts, +} from './dev-stack-port-utils.mjs'; + +function reservePort(port) { + return new Promise((resolve, reject) => { + const server = createServer(); + + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.off('error', reject); + resolve(server); + }); + }); +} + +describe('dev stack port utils', () => { + it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => { + const firstServer = await reservePort(0); + const firstPort = firstServer.address().port; + const secondServer = await reservePort(firstPort + 1); + + try { + const availablePort = await findAvailablePort({ + host: '127.0.0.1', + preferredPort: firstPort, + }); + + expect(availablePort).toBeGreaterThan(firstPort + 1); + } finally { + await Promise.all([ + new Promise((resolve) => firstServer.close(resolve)), + new Promise((resolve) => secondServer.close(resolve)), + ]); + } + }); + + it('为 npm run dev 的所有后续流程解析互不冲突的端口', async () => { + const resolvedPorts = await resolveDevStackPorts({ + spacetime: {host: '127.0.0.1', preferredPort: 0}, + api: {host: '127.0.0.1', preferredPort: 0}, + web: {host: '127.0.0.1', preferredPort: 0}, + adminWeb: {host: '127.0.0.1', preferredPort: 0}, + }); + + expect(new Set(Object.values(resolvedPorts)).size).toBe(4); + }); +}); diff --git a/scripts/dev-web-rust.mjs b/scripts/dev-web-rust.mjs index 98d1ed10..0640c746 100644 --- a/scripts/dev-web-rust.mjs +++ b/scripts/dev-web-rust.mjs @@ -1,6 +1,11 @@ import {spawn} from 'node:child_process'; import {existsSync, readFileSync} from 'node:fs'; import {resolve} from 'node:path'; +import { + findAvailablePort, + formatPortDecision, + normalizePort, +} from './dev-stack-port-utils.mjs'; const repoRoot = process.cwd(); const shellEnvKeys = new Set(Object.keys(process.env)); @@ -121,9 +126,24 @@ const mergedEnv = { console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`); +const webHost = '0.0.0.0'; +const preferredWebPort = normalizePort(fileEnv.WEB_PORT, 3000); +const webPort = await findAvailablePort({ + host: webHost, + preferredPort: preferredWebPort, +}); +console.log( + formatPortDecision({ + name: 'web', + host: webHost, + preferredPort: preferredWebPort, + resolvedPort: webPort, + }), +); + const child = spawn( 'node', - ['scripts/vite-cli.mjs', '--port=3000', '--host=0.0.0.0', '--strictPort'], + ['scripts/vite-cli.mjs', `--port=${webPort}`, `--host=${webHost}`, '--strictPort'], { cwd: process.cwd(), env: mergedEnv,