This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 9f9246b7b fix(tooling): consolidate build and edge contracts (#2853)
9f9246b7b is described below

commit 9f9246b7bce6ff939ee707b3600fb4f6e0e68337
Author: shown <[email protected]>
AuthorDate: Wed Sep 2 15:49:03 2026 +0800

    fix(tooling): consolidate build and edge contracts (#2853)
    
    * [ISSUE #2722] docs(web): define reproducible frontend build inputs
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2721] docs(server): align contributor guidance with the code
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2720] fix(e2e): clean native alert run artifacts
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2731] fix(web): route docs and SSE at the Nginx edge
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    ---------
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 server/README.md                        | 55 +++++++++++-----------
 server/scripts/native-alert-e2e-test.sh | 83 +++++++++++++++++++++++++++++++++
 server/scripts/native-alert-e2e.sh      | 45 ++++++++++++++----
 web/.dockerignore                       |  1 +
 web/README.md                           | 49 +++++++++++++++++++
 web/nginx.conf                          | 44 ++++++++++++++++-
 web/nginx_contract_test.sh              | 54 +++++++++++++++++++++
 web/package-lock.json                   |  3 ++
 web/package.json                        |  3 ++
 9 files changed, 300 insertions(+), 37 deletions(-)

diff --git a/server/README.md b/server/README.md
index 46ff13699..cef879dae 100644
--- a/server/README.md
+++ b/server/README.md
@@ -1,23 +1,23 @@
 # RocketMQ Studio Server — 后端包结构
 
 Spring Boot 3.5 / Java 21 / MyBatis-Plus 单体后端,包根 `org.apache.rocketmq.studio`,
-入口 `StudioApplication`。全部源码约 367 个 Java 文件,按业务域划包,本文档描述各包职责与分层约定。
+入口 `StudioApplication`。源码按业务域划包,本文档描述各包职责与分层约定。
 
 ## 顶层包总览
 
 ```
 org.apache.rocketmq.studio
 ├── StudioApplication          # Spring Boot 入口
-├── auth                       # 登录认证与会话(10 文件)
-├── audit                      # 操作审计(1 文件)
-├── cluster                    # 集群域:拓扑/集群/Proxy/NameServer/指标/K8s(75 文件)
-├── common                     # 公共基础:domain/exception/util/config(26 文件)
-├── instance                   # 实例域:实例注册 + Topic/Group/消息/DLQ/ACL/查询历史(68 文件)
-├── model                      # 遗留共享模型(admin 客户端交互对象)(41 文件)
-├── ops                        # 运维域:AI 助手/告警/审计/Dashboard(73 文件)
-├── persistence                # MyBatis-Plus 实体与 Mapper(32 文件)
-├── provider                   # 多厂商 
SPI、实现与云凭据(apache/alibaba/tencent/credential)(32 文件)
-└── settings                   # 通用设置与数据源(9 文件)
+├── auth                       # 登录认证与会话
+├── audit                      # 操作审计
+├── cluster                    # 集群域:拓扑/集群/Proxy/NameServer/指标/K8s
+├── common                     # 公共基础:domain/exception/util/config
+├── instance                   # 实例域:实例注册 + Topic/Group/消息/DLQ/ACL/查询历史
+├── model                      # 遗留共享模型(admin 客户端交互对象)
+├── ops                        # 运维域:AI 助手/告警/审计/Dashboard
+├── persistence                # MyBatis-Plus 实体与 Mapper
+├── provider                   # 多厂商 
SPI、实现与云凭据(apache/alibaba/tencent/credential)
+└── settings                   # 通用设置与数据源
 ```
 
 ## 各包职责
@@ -38,8 +38,8 @@ org.apache.rocketmq.studio
 | `cluster.config` | Broker 配置更新 DTO/VO |
 
 ### common — 公共基础
-- `common.domain`:`Result<T>` 
统一响应包装、`PageResult`、`BaseEntity`(id/createdAt/updatedAt)、
-  `DeleteRequestDTO`(通用删除入参)、`enums/`(InstanceType、InstanceVendor、TopicType 等 
17 个枚举)
+- `common.domain`:`Result<T>` 
统一响应包装、`PageResult`、`BaseEntity`(id/gmtCreate/gmtModified)、
+  `DeleteRequestDTO`(通用删除入参)、`enums/`(InstanceType、InstanceVendor、TopicType 
等枚举)
 - `common.exception`:`BusinessException(code, msg)` + 
`GlobalExceptionHandler`(统一转 `Result`)
 - `common.util`:`CredentialUtils` 等共享工具
 - `common.config`:CORS / Web MVC 配置
@@ -50,7 +50,7 @@ org.apache.rocketmq.studio
 
 | 子包 | 职责 |
 |---|---|
-| `instance`(顶层) | 实例 CRUD;vendor 分支创建(APACHE 手填 endpoint / ALIYUN 经云目录选择 / 
TENCENT 501) |
+| `instance`(顶层) | 实例 CRUD;APACHE 实例手填 endpoint,ALIYUN/TENCENT 实例经对应云目录选择 |
 | `instance.topic` | Topic CRUD/路由/订阅者、`MetadataService`(按 instanceId 路由到 
provider)、消息发送、LiteTopic 子系统 |
 | `instance.group` | 消费组 CRUD/进度/订阅/重置位点/诊断栈 |
 | `instance.message` | 消息查询与轨迹(`MessageProvider` SPI)、查询历史(与实例绑定,随实例上下文记录/回放) |
@@ -69,7 +69,9 @@ org.apache.rocketmq.studio
 - `provider.alibaba`:阿里云 RocketMQ 5.x OpenAPI 完整实现(`AliyunClientFactory` 按
   credential#region 缓存 AsyncClient、异常统一映射、`AliyunConverters` 集中模型转换、
   `/api/cloud/aliyun/*` 目录端点)
-- `provider.tencent`:占位实现(全部 `UnsupportedOperationException` → 501)
+- `provider.tencent`:腾讯云 RocketMQ 5.x Trocket OpenAPI 
实现;`TencentCatalogService` 提供
+  region/实例目录,`TencentInstanceProvider` 提供 Topic、消费组、消息查询与轨迹能力,
+  `TencentAclService` 提供 ACL 管理,目录端点位于 `/api/cloud/tencent/*`
 - `provider.credential`:云厂商凭据管理(`rmq_cloud_credential` 表 
CRUD,`/api/cloud-credentials`):
   vendor+access_key 唯一键,SK base64 存储,列表打码 + `/{id}/credentials` reveal 接口,
   编解码与打码统一走 `common.util.CredentialUtils`
@@ -93,17 +95,13 @@ org.apache.rocketmq.studio
   云厂商实现放 `provider/<vendor>/` 保持高内聚
 - **敏感字段**:VO 上 `@ToString.Exclude`;存储 base64(见 `CredentialUtils`);
   列表打码、reveal 接口 admin-only
-- **实例标识:禁用 UUID**。系统不使用 UUID(或任何随机代理键)作为实例标识;
-  实例 ID(用户输入、人类可读、全局唯一、≤64 字符)是实例的唯一标识,
-  直接作为 `rmq_instance` 主键(`InstanceService.createInstance` 中 `id = name`),
-  创建后不可变(更新传入不同名称直接 400 `Instance ID cannot be changed after creation`)。
-  REST 参数(`instanceId`)与关联表外键列(`rmq_topic.instance_id`、`rmq_group.instance_id`、
-  ACL scope、数据源绑定等)一律使用实例 ID;不存在"实例名称"概念,
-  实例只有**实例 ID** 与 **备注(remark)** 两个文本属性。
-  解析实例统一走 `InstanceRepository#findByIdentifier`(优先实例 ID,兜底历史主键引用),
-  不要直接 `findById`;存量 UUID 数据经 `deploy/mysql/upgrade-instance-id-pk.sql` 迁移,
-  新功能不得新增随机 ID 作为对外标识
-- **测试命名**:Test 方法名以 `Test` 结尾(如 `syncProxyClusterAddsNewInstanceTest`)
+- **实例标识**:`rmq_instance.id` 是数据库内部使用的自增 `BIGINT` 主键,`name` 是带唯一约束、
+  创建后不可变的人类可读外部标识。REST 的 `instanceId` 参数应通过
+  `InstanceRepository#findByIdentifier` 或 `InstanceService#resolveInstanceId` 
解析:先按唯一名称查找,
+  再兼容数字主键。Topic、消费组和数据源绑定等现有字符串关联保存实例名称;新增代码应区分
+  内部数字主键与外部实例名称,不要假定 `name` 是 `rmq_instance` 的数据库主键
+- **测试命名**:测试类使用 Maven Surefire 可发现的 `*Test` 命名;测试方法使用能描述行为的
+  camelCase 名称,且统一以 `Test` 结尾(如 `shouldReturnPageResultTest`)
 - **checkstyle**:validate 阶段强制,禁止中文字符,Java 代码注释一律用英文
 
 ## 构建与测试
@@ -111,10 +109,11 @@ org.apache.rocketmq.studio
 ```bash
 cd server
 mvn -B -ntp package -DskipTests      # 构建(checkstyle 在 validate 阶段强制)
-mvn -B -ntp -T 32 test               # 全量单测(752 个)
+mvn -B -ntp test                      # 运行全部测试
+mvn -B -ntp -Dtest=AuthServiceTest test  # 运行指定测试类;可替换为目标类名
 ```
 
 依赖纪律:RocketMQ 系依赖(`org.apache.rocketmq:*`)只用 Apache 开源版本(当前基线 5.5.0),
 禁止内部/商业版本号;禁用 `com.aliyun.openservices:ons-client`,客户端收发用开源
 `rocketmq-client`;云厂商管控面走 OpenAPI SDK(`alibabacloud-rocketmq20220801` /
-`tencentcloud-sdk-java-tdmq`)。
+`tencentcloud-sdk-java-trocket`)。
diff --git a/server/scripts/native-alert-e2e-test.sh 
b/server/scripts/native-alert-e2e-test.sh
new file mode 100755
index 000000000..dd2641b5c
--- /dev/null
+++ b/server/scripts/native-alert-e2e-test.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+
+# Lightweight lifecycle regression for native-alert-e2e.sh. It intentionally 
fails at the
+# missing-jar preflight, after temporary artifacts and traps have been 
initialized.
+set -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+SUBJECT="$SCRIPT_DIR/native-alert-e2e.sh"
+TEST_TMP=$(mktemp -d "${TMPDIR:-/tmp}/native-alert-e2e-test.XXXXXX")
+BIN_DIR="$TEST_TMP/bin"
+mkdir "$BIN_DIR"
+
+cleanup() {
+  for command in curl jq mysql java; do
+    [[ ! -e "$BIN_DIR/$command" ]] || unlink "$BIN_DIR/$command"
+  done
+  rmdir "$BIN_DIR" 2>/dev/null || true
+  rmdir "$TEST_TMP" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+for command in curl jq mysql java; do
+  printf '#!/usr/bin/env sh\nexit 0\n' >"$BIN_DIR/$command"
+  chmod +x "$BIN_DIR/$command"
+done
+
+run_subject() {
+  env \
+    PATH="$BIN_DIR:/usr/bin:/bin" \
+    TMPDIR="$TEST_TMP" \
+    E2E_DB_JDBC_URL=jdbc:mysql://127.0.0.1/test \
+    E2E_MYSQL_DATABASE=test \
+    E2E_ADMIN_USERNAME=admin \
+    E2E_ADMIN_PASSWORD=password \
+    E2E_NAMESRV_ADDR=127.0.0.1:9876 \
+    E2E_WEBHOOK_URL=http://receiver.invalid/hook \
+    E2E_WEBHOOK_ASSERT_URL=http://receiver.invalid/assert \
+    [email protected] \
+    E2E_SMTP_HOST=127.0.0.1 \
+    E2E_STUDIO_JAR="$TEST_TMP/missing.jar" \
+    E2E_KEEP_ARTIFACTS="$1" \
+    E2E_PORT="${2:-18083}" \
+    bash "$SUBJECT" >/dev/null 2>&1
+}
+
+set +e
+run_subject false 0
+status=$?
+set -e
+[[ "$status" -eq 2 ]] || { echo "Expected invalid port exit 2, got $status" 
>&2; exit 1; }
+if compgen -G "$TEST_TMP/rocketmq-studio-e2e.*" >/dev/null; then
+  echo 'Argument validation created temporary artifacts' >&2
+  exit 1
+fi
+
+set +e
+run_subject false
+status=$?
+set -e
+[[ "$status" -eq 2 ]] || { echo "Expected missing jar exit 2, got $status" 
>&2; exit 1; }
+if compgen -G "$TEST_TMP/rocketmq-studio-e2e.*" >/dev/null; then
+  echo 'Default cleanup left temporary artifacts behind' >&2
+  exit 1
+fi
+
+set +e
+run_subject true
+status=$?
+set -e
+[[ "$status" -eq 2 ]] || { echo "Expected retained missing jar exit 2, got 
$status" >&2; exit 1; }
+shopt -s nullglob
+artifacts=("$TEST_TMP"/rocketmq-studio-e2e.*)
+shopt -u nullglob
+[[ "${#artifacts[@]}" -eq 2 ]] || { echo 'Artifact retention did not keep both 
paths' >&2; exit 1; }
+for artifact in "${artifacts[@]}"; do
+  if [[ -d "$artifact" ]]; then
+    rmdir "$artifact"
+  else
+    unlink "$artifact"
+  fi
+done
+
+echo 'native-alert-e2e lifecycle checks passed'
diff --git a/server/scripts/native-alert-e2e.sh 
b/server/scripts/native-alert-e2e.sh
index 3bc6bb150..b14a07f6f 100755
--- a/server/scripts/native-alert-e2e.sh
+++ b/server/scripts/native-alert-e2e.sh
@@ -19,6 +19,7 @@
 #   E2E_DB_USERNAME=root E2E_DB_PASSWORD=studio123 E2E_MYSQL_HOST=127.0.0.1
 #   E2E_MYSQL_PORT=3306 E2E_SMTP_PORT=1025 E2E_PORT=18083 
E2E_SILENCE_SECONDS=10
 #   E2E_STUDIO_JAR=.../server/target/rocketmq-studio-1.0.0.jar
+#   E2E_KEEP_ARTIFACTS=true keeps the cookie file and Studio log for debugging
 #
 # The script retains its e2e-native-alert-* records as database evidence. It 
does not
 # use an operator's development database and it stops the temporary Studio 
process.
@@ -30,10 +31,6 @@ required=(E2E_DB_JDBC_URL E2E_MYSQL_DATABASE 
E2E_ADMIN_USERNAME E2E_ADMIN_PASSWO
 for name in "${required[@]}"; do
   [[ -n "${!name:-}" ]] || { echo "Missing required environment variable: 
$name" >&2; exit 2; }
 done
-for command in curl jq mysql; do
-  command -v "$command" >/dev/null || { echo "Required command is unavailable: 
$command" >&2; exit 2; }
-done
-
 SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
 SERVER_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
 JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
@@ -45,24 +42,52 @@ MYSQL_HOST=${E2E_MYSQL_HOST:-127.0.0.1}
 MYSQL_PORT=${E2E_MYSQL_PORT:-3306}
 DB_USERNAME=${E2E_DB_USERNAME:-root}
 DB_PASSWORD=${E2E_DB_PASSWORD:-studio123}
+KEEP_ARTIFACTS=${E2E_KEEP_ARTIFACTS:-false}
 RUN_ID="e2e-native-alert-$(date -u +%Y%m%d%H%M%S)-$$"
 INSTANCE_ID="$RUN_ID-instance"
 RULE_NAME="$RUN_ID-rule"
-COOKIE=$(mktemp "${TMPDIR:-/tmp}/rocketmq-studio-e2e.cookie.XXXXXX")
-RUN_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rocketmq-studio-e2e.XXXXXX")
-APP_LOG="$RUN_DIR/studio.log"
+COOKIE=
+RUN_DIR=
+APP_LOG=
 APP_PID=
 
+require_port() {
+  local name=$1
+  local value=$2
+  [[ "$value" =~ ^[0-9]+$ && "$value" -ge 1 && "$value" -le 65535 ]] \
+    || { echo "$name must be an integer between 1 and 65535" >&2; exit 2; }
+}
+
+for command in curl jq mysql; do
+  command -v "$command" >/dev/null || { echo "Required command is unavailable: 
$command" >&2; exit 2; }
+done
+command -v "$JAVA_BIN" >/dev/null || { echo "Required Java command is 
unavailable: $JAVA_BIN" >&2; exit 2; }
+require_port E2E_PORT "$PORT"
+require_port E2E_SMTP_PORT "$SMTP_PORT"
+require_port E2E_MYSQL_PORT "$MYSQL_PORT"
 [[ "$SILENCE_SECONDS" =~ ^[0-9]+$ && "$SILENCE_SECONDS" -ge 5 ]] \
   || { echo 'E2E_SILENCE_SECONDS must be an integer of at least 5' >&2; exit 
2; }
+[[ "$KEEP_ARTIFACTS" == true || "$KEEP_ARTIFACTS" == false ]] \
+  || { echo 'E2E_KEEP_ARTIFACTS must be true or false' >&2; exit 2; }
+
+COOKIE=$(mktemp "${TMPDIR:-/tmp}/rocketmq-studio-e2e.cookie.XXXXXX")
+RUN_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rocketmq-studio-e2e.XXXXXX")
+APP_LOG="$RUN_DIR/studio.log"
 
 cleanup() {
   if [[ -n "$APP_PID" ]]; then
     kill "$APP_PID" 2>/dev/null || true
     wait "$APP_PID" 2>/dev/null || true
   fi
+  if [[ "$KEEP_ARTIFACTS" != true ]]; then
+    [[ -z "$COOKIE" || ! -e "$COOKIE" ]] || unlink "$COOKIE"
+    [[ -z "$APP_LOG" || ! -e "$APP_LOG" ]] || unlink "$APP_LOG"
+    [[ -z "$RUN_DIR" || ! -d "$RUN_DIR" ]] || rmdir "$RUN_DIR" 2>/dev/null || 
true
+  fi
 }
 trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
 
 [[ -f "$STUDIO_JAR" ]] || { echo "Studio jar not found: $STUDIO_JAR" >&2; exit 
2; }
 
@@ -182,4 +207,8 @@ grep -q 'FIRING' <<<"$WEBHOOK_PAYLOADS" || { echo 'Webhook 
capture has no FIRING
 grep -q 'RESOLVED' <<<"$WEBHOOK_PAYLOADS" || { echo 'Webhook capture has no 
RESOLVED payload' >&2; exit 1; }
 
 echo "PASS: silence suppression, FIRING/RESOLVED state transitions, SMTP 
handoff, and webhook delivery verified."
-echo "Evidence is retained in $E2E_MYSQL_DATABASE for instance $INSTANCE_ID; 
Studio log: $APP_LOG"
+if [[ "$KEEP_ARTIFACTS" == true ]]; then
+  echo "Evidence is retained in $E2E_MYSQL_DATABASE for instance $INSTANCE_ID; 
Studio log: $APP_LOG"
+else
+  echo "Database evidence is retained in $E2E_MYSQL_DATABASE for instance 
$INSTANCE_ID."
+fi
diff --git a/web/.dockerignore b/web/.dockerignore
index 03b94481b..bbeadf0ea 100644
--- a/web/.dockerignore
+++ b/web/.dockerignore
@@ -7,4 +7,5 @@ dist
 .DS_Store
 .env
 .env.local
+.env.*.local
 coverage
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 000000000..fd6fe4f69
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,49 @@
+# RocketMQ Studio 前端开发指南
+
+前端基于 React、TypeScript 和 Vite,使用 npm 锁定依赖。请从仓库根目录进入 `web` 目录后再执行下列命令。
+
+## 环境要求
+
+- Node.js 20.19.0 或更高版本;该下限与部署运行时(server/Dockerfile)保持一致(当前 Vite 依赖本身仅要求 Node 
18+)。
+- npm;提交依赖变更时必须同步更新 `package-lock.json`。
+
+```bash
+cd web
+node --version
+npm ci
+```
+
+`npm ci` 会严格按锁文件安装依赖,适用于首次安装和 CI。日常更新依赖时使用 `npm install`,并一并审查清单与锁文件的差异。
+
+## 本地开发
+
+```bash
+npm run dev
+```
+
+开发服务器默认使用 mock 数据。若要连接本地后端,请在不提交到仓库的 `.env.local` 中配置:
+
+```dotenv
+VITE_USE_MOCK=false
+VITE_API_PROXY_TARGET=http://localhost:8888
+```
+
+`VITE_API_BASE_URL` 可覆盖浏览器请求前缀,默认值为 `/api`。`.env.local` 与 `.env.*.local` 
是开发者私有覆盖文件,已从 Git 和 Docker 构建上下文中排除,不应存放到版本库。
+
+## 质量检查
+
+提交前至少运行与改动相关的测试,并执行静态检查和构建:
+
+```bash
+npm test
+npm run lint
+npm run build
+```
+
+格式化指定源码可使用:
+
+```bash
+npx prettier --check src
+```
+
+生产构建会由 Vite 注入构建提交和构建时间,生成产物位于 `dist/`。Docker 
构建只复制受版本控制的清单、锁文件和源码;本地依赖、产物及环境覆盖文件不进入镜像上下文。
diff --git a/web/nginx.conf b/web/nginx.conf
index aae900005..3535a9707 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -18,7 +18,7 @@ server {
     #   brotli_comp_level 6;
     #   brotli_types text/plain text/css application/javascript 
application/json image/svg+xml;
 
-    # RESOLVER is injected by 15-resolver.sh (podman network gateway only).
+    # RESOLVER is injected by 15-resolver.envsh (podman network gateway only).
     # Variable proxy_pass re-resolves on every request, so a recreated
     # rocketmq-server container with a new IP is picked up without restarting
     # nginx.
@@ -53,6 +53,48 @@ server {
         proxy_set_header X-Forwarded-Proto $scheme;
     }
 
+    # Keep streaming configuration scoped to the only SSE endpoint. Other API
+    # responses retain the default proxy buffering and timeout behavior.
+    location = /api/ai/chat {
+        set $backend_ai_chat http://rocketmq-server:8888;
+        proxy_pass $backend_ai_chat;
+        proxy_buffering off;
+        proxy_read_timeout 360s;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # Springdoc endpoints live outside /api/ and must bypass the SPA fallback.
+    location = /api-docs {
+        set $backend_api_docs http://rocketmq-server:8888;
+        proxy_pass $backend_api_docs;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    location ^~ /api-docs/ {
+        set $backend_api_docs_assets http://rocketmq-server:8888;
+        proxy_pass $backend_api_docs_assets;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    location = /swagger-ui.html {
+        set $backend_swagger_redirect http://rocketmq-server:8888;
+        proxy_pass $backend_swagger_redirect;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    location ^~ /swagger-ui/ {
+        set $backend_swagger_assets http://rocketmq-server:8888;
+        proxy_pass $backend_swagger_assets;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
     location = /livez {
         set $backend_livez http://rocketmq-server:8888;
         proxy_pass $backend_livez;
diff --git a/web/nginx_contract_test.sh b/web/nginx_contract_test.sh
new file mode 100644
index 000000000..9f482e573
--- /dev/null
+++ b/web/nginx_contract_test.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+set -eu
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
+CONFIG=${1:-"$SCRIPT_DIR/nginx.conf"}
+
+fail() {
+    echo "nginx contract test failed: $*" >&2
+    exit 1
+}
+
+location_block() {
+    awk -v header="$1" '
+        index($0, header) { inside = 1 }
+        inside {
+            print
+            opened += gsub(/\{/, "{")
+            closed += gsub(/\}/, "}")
+            if (opened > 0 && opened == closed) exit
+        }
+    ' "$CONFIG"
+}
+
+require_directive() {
+    block=$(location_block "$1")
+    [ -n "$block" ] || fail "missing block: $1"
+    printf '%s\n' "$block" | grep -F "$2" >/dev/null \
+        || fail "$1 is missing: $2"
+}
+
+require_directive "location = /api-docs {" "proxy_pass \$backend_api_docs;"
+require_directive "location ^~ /api-docs/ {" "proxy_pass 
\$backend_api_docs_assets;"
+require_directive "location = /swagger-ui.html {" "proxy_pass 
\$backend_swagger_redirect;"
+require_directive "location ^~ /swagger-ui/ {" "proxy_pass 
\$backend_swagger_assets;"
+require_directive "location = /api/ai/chat {" 'proxy_buffering off;'
+require_directive "location = /api/ai/chat {" 'proxy_read_timeout 360s;'
+
+api_block=$(location_block "location /api/ {")
+printf '%s\n' "$api_block" | grep -F 'proxy_buffering off;' >/dev/null \
+    && fail "generic /api/ block must keep default buffering"
+printf '%s\n' "$api_block" | grep -F 'proxy_read_timeout' >/dev/null \
+    && fail "generic /api/ block must keep the default read timeout"
+
+[ "$(grep -F -c 'proxy_buffering off;' "$CONFIG")" -eq 1 ] \
+    || fail "proxy buffering must be disabled only for the SSE endpoint"
+[ "$(grep -F -c 'proxy_read_timeout 360s;' "$CONFIG")" -eq 1 ] \
+    || fail "extended read timeout must be scoped to the SSE endpoint"
+
+echo "nginx edge contract: PASS"
diff --git a/web/package-lock.json b/web/package-lock.json
index e547b623e..363f0e254 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -7,6 +7,9 @@
     "": {
       "name": "rocketmq-studio-web",
       "version": "0.1.0",
+      "engines": {
+        "node": ">=20.19.0"
+      },
       "dependencies": {
         "@ant-design/icons": "^5.5.0",
         "@phosphor-icons/react": "^2.1.10",
diff --git a/web/package.json b/web/package.json
index 90f2c25e4..d70264374 100644
--- a/web/package.json
+++ b/web/package.json
@@ -3,6 +3,9 @@
   "private": true,
   "version": "0.1.0",
   "type": "module",
+  "engines": {
+    "node": ">=20.19.0"
+  },
   "scripts": {
     "dev": "vite",
     "build": "tsc -b && vite build",

Reply via email to