fix(dev): resolve local stack ports before startup
This commit is contained in:
@@ -87,8 +87,8 @@
|
|||||||
|
|
||||||
- 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。
|
- 现象:本地 `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 standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。
|
||||||
- 处理:`3101` 已被可复用 standalone 占用时使用 `npm run dev -- --skip-spacetime`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。
|
- 处理:`3101` 已被可复用 standalone 占用时使用 `npm run dev -- --skip-spacetime`;未修改 `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`;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。
|
- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`--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`。
|
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。
|
||||||
|
|
||||||
## 本地 SpacetimeDB publish 401 可清本地库重发
|
## 本地 SpacetimeDB publish 401 可清本地库重发
|
||||||
|
|||||||
127
.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md
Normal file
127
.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md
Normal file
@@ -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:<web-port>/`。
|
||||||
|
2. Rust `api-server`:`8082`,健康检查为 `http://127.0.0.1:<api-port>/healthz`。
|
||||||
|
3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:<spacetime-port>/v1/ping`。
|
||||||
|
4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:<admin-web-port>/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://...:<actual-api-port>`
|
||||||
|
- `[dev:rust] spacetime: http://...:<actual-spacetime-port>`
|
||||||
|
- 主站和后台 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`。
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
||||||
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
|
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
|
||||||
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。
|
- 涉及 `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` 执行。
|
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。
|
||||||
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。
|
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。
|
||||||
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
||||||
|
|||||||
@@ -25,23 +25,32 @@ npm run dev:rust
|
|||||||
|
|
||||||
默认端口:
|
默认端口:
|
||||||
|
|
||||||
1. Web 前端:`http://127.0.0.1:3000`
|
1. Web 前端:优先 `http://127.0.0.1:3000`
|
||||||
2. Rust `api-server`:`http://127.0.0.1:8082`
|
2. Rust `api-server`:优先 `http://127.0.0.1:8082`
|
||||||
3. SpacetimeDB standalone:`http://127.0.0.1:3101`
|
3. SpacetimeDB standalone:优先 `http://127.0.0.1:3101`
|
||||||
4. SpacetimeDB database:优先读取仓库根目录 `spacetime.local.json` 的 `database` 字段;没有该字段时才回退到 `genarrative-dev`
|
4. 后台 Web 前端:优先 `http://127.0.0.1:3102`
|
||||||
5. SpacetimeDB 本地数据与日志目录:`server-rs/.spacetimedb/local`
|
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。
|
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/`。
|
2. 检查并解析本次联调需要使用的端口;端口不可用时先寻找可用端口,再把实际端口传给后续流程。
|
||||||
3. 启动 `spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101`,确保本地数据库与 SpacetimeDB 内部日志不会落到开发者全局目录。
|
3. Windows Git Bash 下如 `server-rs/.spacetimedb/local/bin/current/spacetimedb-cli.exe` 不存在,先把本机 `spacetime` 所在安装目录的 `bin/` 与 `spacetime.exe` 同步到 `server-rs/.spacetimedb/local/`。
|
||||||
4. 等待 SpacetimeDB 就绪:优先接受 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:3101` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:3101/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
4. 启动 `spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:<spacetime-port>`,确保本地数据库与 SpacetimeDB 内部日志不会落到开发者全局目录。
|
||||||
5. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
|
5. 等待 SpacetimeDB 就绪:优先接受 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:<spacetime-port>` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:<spacetime-port>/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
||||||
6. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
|
6. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:<spacetime-port> --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
|
||||||
7. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
7. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
|
||||||
8. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
8. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
||||||
9. 任一子进程退出时,脚本回收其余子进程。
|
9. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动主站 Vite;注入 `ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET` 后启动后台 Vite。
|
||||||
|
10. 任一子进程退出时,脚本回收其余子进程。
|
||||||
|
|
||||||
Vite 代理覆盖范围:
|
Vite 代理覆盖范围:
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ usage() {
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。
|
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。
|
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
|
||||||
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;端口被占用时自动接受 SpacetimeDB 建议的最近可用端口。
|
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;端口被占用时会先探测可用端口,再把实际端口传给后续流程。
|
||||||
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
|
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -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() {
|
cleanup() {
|
||||||
local index
|
local index
|
||||||
|
|
||||||
@@ -530,8 +549,10 @@ fi
|
|||||||
|
|
||||||
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
|
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||||
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
|
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}")"
|
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
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
@@ -561,9 +582,8 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
|||||||
echo "[dev:rust] 启动 spacetimedb"
|
echo "[dev:rust] 启动 spacetimedb"
|
||||||
(
|
(
|
||||||
cd "${SERVER_RS_DIR}"
|
cd "${SERVER_RS_DIR}"
|
||||||
# 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口;
|
# 端口已在启动前完成可用性检查;这里仍从启动日志解析实际监听端口,防止外部并发占用导致 SpacetimeDB 自行漂移。
|
||||||
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
|
spacetime \
|
||||||
printf '\n' | spacetime \
|
|
||||||
start \
|
start \
|
||||||
--data-dir "${SPACETIME_DATA_DIR}" \
|
--data-dir "${SPACETIME_DATA_DIR}" \
|
||||||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
||||||
|
|||||||
164
scripts/dev-stack-port-utils.mjs
Normal file
164
scripts/dev-stack-port-utils.mjs
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
scripts/dev-stack-port-utils.test.ts
Normal file
51
scripts/dev-stack-port-utils.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import {spawn} from 'node:child_process';
|
import {spawn} from 'node:child_process';
|
||||||
import {existsSync, readFileSync} from 'node:fs';
|
import {existsSync, readFileSync} from 'node:fs';
|
||||||
import {resolve} from 'node:path';
|
import {resolve} from 'node:path';
|
||||||
|
import {
|
||||||
|
findAvailablePort,
|
||||||
|
formatPortDecision,
|
||||||
|
normalizePort,
|
||||||
|
} from './dev-stack-port-utils.mjs';
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
const shellEnvKeys = new Set(Object.keys(process.env));
|
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}`);
|
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(
|
const child = spawn(
|
||||||
'node',
|
'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(),
|
cwd: process.cwd(),
|
||||||
env: mergedEnv,
|
env: mergedEnv,
|
||||||
|
|||||||
Reference in New Issue
Block a user