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",