Skip to content

Commit 126b6d0

Browse files
committed
feat:jsonPublicPath decoupling
1 parent e1a1f44 commit 126b6d0

12 files changed

Lines changed: 368 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
---
1616

17+
## [0.2.1] - 2026-03-24
18+
19+
> 📄 [Detailed changelog →](./changelogs/v0.2.1.md)
20+
21+
### Added
22+
- **OPENAPI-012**: `jsonPublicPath` 配置项 — 解耦 vext 内部路由注册路径与 Scalar HTML 中引用 spec 的公开 URL。用于反向代理剥离路径前缀场景(如 Nginx `/admin/*` → vext `/`),配置后 Scalar "Try it out" 可正确获取 spec,不再因绝对路径丢失代理前缀而 404。新增字段:`VextOpenAPIConfig.jsonPublicPath``src/types/app.ts`)、`DocEndpointsConfig.specPublicPath``src/lib/openapi/types.ts`),三处 bootstrap 入口同步透传(`bootstrap.ts` / `dev-bootstrap.ts` / `route-reloader.ts`)。
23+
24+
### Fixed
25+
- **BUG-033**: 反向代理前缀剥离场景下 Scalar "Try it out" 请求发往错误地址 — `doc-endpoints.ts` 生成 Scalar HTML 时 `specUrl` 硬编码为内部路由路径(绝对路径),浏览器请求时丢失代理前缀导致 404。修复为引入 `specPublicPath` 字段,允许独立配置 Scalar HTML 中的 spec URL,内部路由注册路径不受影响。
26+
27+
### Docs
28+
- **DOCS-003**: `website/docs/guide/openapi.md` 新增"反向代理路径前缀场景"章节,覆盖代理剥离/透传两种策略、`jsonPublicPath` 用法、请求链路说明,以及 `servers` 字段用途说明。
29+
- **DOCS-004**: 修正 `website/docs/guide/configuration.md``website/docs/guide/openapi.md` 中错误的 `specPath` 字段名(正确为 `jsonPath`);`website/docs/api/config.md` 新增 `jsonPublicPath` 行。
30+
31+
---
32+
1733
## [0.2.0] - 2026-03-24
1834

1935
> 📄 [Detailed changelog →](./changelogs/v0.2.0.md)

changelogs/v0.2.1.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# v0.2.1 - 2026-03-24
2+
3+
## Highlights
4+
5+
- **OPENAPI-012**: 新增 `jsonPublicPath` 配置项,解决反向代理剥离路径前缀场景下 Scalar "Try it out" 请求发往错误地址的问题
6+
- **BUG-033**: 修复 `doc-endpoints.ts` 中 Scalar HTML spec URL 与路由注册路径强耦合的缺陷
7+
- **DOCS-003/004**: 补充反向代理部署文档,修正多处字段名错误
8+
9+
---
10+
11+
## New Features
12+
13+
### OPENAPI-012: `jsonPublicPath` — Scalar HTML spec URL 与路由注册路径解耦
14+
15+
**背景**
16+
17+
`vext` 的 OpenAPI 模块此前用同一个路径(`jsonPath`)同时控制两件事:
18+
19+
1. vext 内部路由注册路径(`app.adapter.registerRoute("GET", specPath, ...)`
20+
2. Scalar HTML 中 `Scalar.createApiReference``url` 字段
21+
22+
当应用部署在 Nginx 反向代理后、且代理**剥离**路径前缀时(`proxy_pass` 末尾带 `/`),两个路径需要不同的值:
23+
24+
- vext 内部路由:`/openapi.json`(代理已剥离前缀,vext 收到的是干净路径)
25+
- Scalar HTML 引用:`/admin/openapi.json`(浏览器需要带前缀才能命中 Nginx 规则)
26+
27+
由于两者耦合,原先只能二选一,导致要么路由404,要么 Scalar 获取 spec 失败。
28+
29+
**变更内容**
30+
31+
新增 `jsonPublicPath` 配置字段,专门控制 Scalar HTML 中引用 spec 的 URL,与路由注册路径完全独立:
32+
33+
```typescript
34+
// config/production.ts
35+
export default {
36+
openapi: {
37+
enabled: true,
38+
jsonPath: '/openapi.json', // vext 内部路由(Nginx 剥离前缀后收到 /openapi.json)
39+
docsPath: '/docs',
40+
jsonPublicPath: '/admin/openapi.json', // Scalar HTML 引用地址(浏览器侧完整路径)
41+
},
42+
};
43+
```
44+
45+
请求链路(代理剥离前缀场景):
46+
47+
```
48+
浏览器 GET /admin/docs
49+
→ Nginx 剥离 /admin → vext GET /docs → 返回 Scalar HTML(url = /admin/openapi.json)
50+
→ Scalar fetch /admin/openapi.json
51+
→ Nginx 剥离 /admin → vext GET /openapi.json → 返回 spec ✅
52+
```
53+
54+
未设置 `jsonPublicPath` 时,默认值与 `jsonPath` 相同,行为与升级前完全一致(向后兼容)。
55+
56+
**新增文件/字段**
57+
58+
| 位置 | 变更 |
59+
|------|------|
60+
| `src/types/app.ts``VextOpenAPIConfig` | 新增 `jsonPublicPath?: string` |
61+
| `src/lib/openapi/types.ts``DocEndpointsConfig` | 新增 `specPublicPath?: string` |
62+
| `src/lib/openapi/doc-endpoints.ts` | `generateScalarHTML` 改用 `specPublicPath ?? specPath` |
63+
| `src/lib/bootstrap.ts` | 透传 `jsonPublicPath``specPublicPath` |
64+
| `src/lib/dev/dev-bootstrap.ts` | 同上 |
65+
| `src/lib/dev/route-reloader.ts` | 同上 |
66+
67+
---
68+
69+
## Bug Fixes
70+
71+
### BUG-033: 反向代理前缀剥离场景 Scalar "Try it out" 404
72+
73+
**问题描述**
74+
75+
应用部署在 Nginx `/admin/` 代理前缀下(代理剥离前缀),访问 `https://example.com/admin/docs` 时,Scalar UI 尝试 fetch `/openapi.json`(绝对路径),浏览器请求发往 `https://example.com/openapi.json`,绕过了 Nginx 的 `/admin/` 规则,返回 404。
76+
77+
**根因**
78+
79+
`doc-endpoints.ts``generateScalarHTML(specPath, ...)` 直接将内部路由注册路径(`/openapi.json`)写入 Scalar HTML 的 `url` 字段。绝对路径在浏览器中始终相对于 origin 解析,无法感知代理前缀。
80+
81+
**修复**
82+
83+
引入 `specPublicPath` 字段(由 `jsonPublicPath` 配置驱动),在生成 Scalar HTML 时使用 `specPublicPath ?? specPath`,使两者可以独立配置:
84+
85+
```typescript
86+
// doc-endpoints.ts (修复后)
87+
const specPublicPath = config.specPublicPath ?? specPath;
88+
// ...
89+
const html = generateScalarHTML(specPublicPath, scalarConfig); // ← 用公开路径
90+
app.adapter.registerRoute("GET", specPath, [...]); // ← 用内部路由
91+
```
92+
93+
**影响范围**
94+
95+
- 仅影响配置了反向代理前缀剥离的部署场景
96+
- 未配置 `jsonPublicPath` 时行为与修复前完全一致(向后兼容)
97+
98+
---
99+
100+
## Documentation
101+
102+
### DOCS-003: openapi.md 新增反向代理部署章节
103+
104+
`website/docs/guide/openapi.md` 的"自定义文档路径"章节下新增:
105+
106+
- **反向代理路径前缀场景** — 详细说明代理**剥离前缀****透传前缀**两种策略的配置方式
107+
- 情况一(剥离前缀):`jsonPath` 保持默认,配置 `jsonPublicPath` 为带前缀的公开路径,附完整请求链路图
108+
- 情况二(透传前缀):配置 `jsonPath` / `docsPath` 为带前缀的路径,无需配置 `jsonPublicPath`
109+
- 两种情况对比表格
110+
- **`servers` 字段说明** — 解释 `servers` 是写入 OpenAPI spec 本身的元数据,仅影响 Scalar "Try it out" 的目标地址;默认值 `"/"` 跟随当前域名,多数情况无需配置;需要多环境切换下拉框时才显式配置
111+
112+
### DOCS-004: 修正多处字段名错误
113+
114+
| 文件 | 问题 | 修复 |
115+
|------|------|------|
116+
| `website/docs/guide/configuration.md` | 表格和代码示例中用了不存在的 `specPath` 字段 | 修正为 `jsonPath`,新增 `jsonPublicPath`|
117+
| `website/docs/guide/openapi.md` | 全局配置示例中用了 `specPath` | 修正为 `jsonPath`,注释说明 `jsonPublicPath` |
118+
| `website/docs/api/config.md` | `VextOpenAPIConfig` 表格缺少 `jsonPublicPath`|`jsonPath` 行后新增 |
119+
120+
---
121+
122+
## Validation
123+
124+
`vext-test` 项目中通过端到端验证(`verify-openapi-public-path.mjs`):
125+
126+
```
127+
[PASS] GET /openapi.json → 200 OK(vext 内部路由注册正常)
128+
[PASS] GET /docs → 200 OK(文档页面路由正常)
129+
[PASS] Scalar HTML 中 spec url = /proxy/openapi.json ✅(jsonPublicPath 生效)
130+
[PASS] Scalar HTML 中 url 字段不含内部路径 /openapi.json(路由路径与公开路径已解耦)
131+
[PASS] openapi 版本字段: 3.0.3
132+
[PASS] info.title: "VextJS Test API"
133+
[PASS] paths 包含 128 个路由
134+
[PASS] servers[0].url: "http://127.0.0.1:3000"
135+
[PASS] OpenAPI spec 内容中不含 /proxy/openapi.json(spec 与 Scalar 配置正确隔离)
136+
137+
通过: 9 / 失败: 0
138+
```
139+
140+
全量单元测试:**2329 tests passed**`npm test`
141+
142+
TypeScript 类型检查:**0 errors**`tsc --noEmit`
143+
144+
---
145+
146+
## Migration Guide
147+
148+
### 无破坏性变更
149+
150+
`jsonPublicPath` 是纯新增可选字段,默认值为 `jsonPath` 的值,升级后行为与 v0.2.0 完全一致。
151+
152+
### 受影响场景:反向代理剥离前缀部署
153+
154+
如果你的部署满足以下条件:
155+
156+
1. 应用通过 Nginx(或其他反向代理)暴露,且代理**剥离**路径前缀
157+
2. 访问 `/admin/docs` 后 Scalar 无法加载 spec(浏览器控制台报 404)
158+
159+
则需要添加 `jsonPublicPath` 配置:
160+
161+
```typescript
162+
// config/production.ts(或对应环境配置文件)
163+
export default {
164+
openapi: {
165+
enabled: true,
166+
jsonPublicPath: '/admin/openapi.json', // 替换为你的实际代理前缀
167+
},
168+
};
169+
```
170+
171+
其他配置无需修改。

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vextjs",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "VextJS is a modern Node.js web framework with adapter architecture, plugin system, and out-of-the-box features for building high-performance RESTful APIs.",
55
"type": "module",
66
"main": "./dist/index.js",

src/lib/bootstrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ export async function bootstrap(rootDir: string): Promise<BootstrapResult> {
267267

268268
registerDocEndpoints(app, spec, {
269269
specPath: openapiConfig?.jsonPath ?? "/openapi.json",
270+
specPublicPath: (openapiConfig as Record<string, unknown>)
271+
?.jsonPublicPath as string | undefined,
270272
docsPath: openapiConfig?.docsPath ?? "/docs",
271273
title: openapiConfig?.title,
272274
scalar: (openapiConfig as Record<string, unknown>)?.scalar as

src/lib/dev/dev-bootstrap.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ export async function devBootstrap(
387387

388388
registerDocEndpoints(app, spec, {
389389
specPath: openapiConfig?.jsonPath ?? "/openapi.json",
390+
specPublicPath: (openapiConfig as Record<string, unknown>)
391+
?.jsonPublicPath as string | undefined,
390392
docsPath: openapiConfig?.docsPath ?? "/docs",
391393
title: openapiConfig?.title,
392394
scalar: (openapiConfig as Record<string, unknown>)?.scalar as
@@ -445,11 +447,20 @@ export async function devBootstrap(
445447
// 错误处理 + 404
446448
// 🆕 Dev 错误覆盖层:读取 config.dev.errorOverlay 配置,enabled !== false 时注入
447449
const devOverlayConfig = (config as Record<string, unknown>).dev as
448-
| { errorOverlay?: { enabled?: boolean; theme?: "dark" | "light"; maxFrames?: number } }
450+
| {
451+
errorOverlay?: {
452+
enabled?: boolean;
453+
theme?: "dark" | "light";
454+
maxFrames?: number;
455+
};
456+
}
449457
| undefined;
450458
const overlayEnabled = devOverlayConfig?.errorOverlay?.enabled !== false;
451459
const overlayOptions = devOverlayConfig?.errorOverlay
452-
? { theme: devOverlayConfig.errorOverlay.theme, maxFrames: devOverlayConfig.errorOverlay.maxFrames }
460+
? {
461+
theme: devOverlayConfig.errorOverlay.theme,
462+
maxFrames: devOverlayConfig.errorOverlay.maxFrames,
463+
}
453464
: undefined;
454465
const overlayFn = overlayEnabled
455466
? (err: unknown) => renderDevErrorPage(err, projectRoot, overlayOptions)
@@ -610,7 +621,8 @@ export async function devBootstrap(
610621
(cfg as any).response ?? {},
611622
// 🆕 soft reload 后重建的错误处理器同样包含 overlay 注入
612623
overlayEnabled
613-
? (err: unknown) => renderDevErrorPage(err, projectRoot, overlayOptions)
624+
? (err: unknown) =>
625+
renderDevErrorPage(err, projectRoot, overlayOptions)
614626
: undefined,
615627
)) as any,
616628
createNotFoundHandler: createNotFoundHandler as any,

src/lib/dev/route-reloader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,8 @@ export async function reloadRoutes(
527527
specPath:
528528
((openapiCfg as Record<string, unknown>)?.jsonPath as string) ??
529529
"/openapi.json",
530+
specPublicPath: (openapiCfg as Record<string, unknown>)
531+
?.jsonPublicPath as string | undefined,
530532
docsPath:
531533
((openapiCfg as Record<string, unknown>)?.docsPath as string) ??
532534
"/docs",

src/lib/openapi/doc-endpoints.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function registerDocEndpoints(
5151
config: DocEndpointsConfig,
5252
): void {
5353
const specPath = config.specPath ?? "/openapi.json";
54+
const specPublicPath = config.specPublicPath ?? specPath;
5455
const docsPath = config.docsPath ?? "/docs";
5556
const title = config.title ?? "API Documentation";
5657

@@ -87,7 +88,7 @@ export function registerDocEndpoints(
8788

8889
app.adapter.registerRoute("GET", docsPath, [
8990
async (_req, res) => {
90-
const html = generateScalarHTML(specPath, scalarConfig);
91+
const html = generateScalarHTML(specPublicPath, scalarConfig);
9192
res.setHeader("Content-Type", "text/html; charset=utf-8");
9293
res.text(html);
9394
},

src/lib/openapi/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,30 @@ export interface DocEndpointsConfig {
457457
/** OpenAPI spec 路径 @default '/openapi.json' */
458458
specPath?: string;
459459

460+
/**
461+
* OpenAPI spec 的公开访问路径(用于 Scalar HTML 中引用 spec 的 URL)
462+
*
463+
* 仅影响 Scalar HTML 里 `url` 字段的值,**不影响** vext 内部路由注册路径。
464+
* 未设置时默认与 `specPath` 相同。
465+
*
466+
* **使用场景**:应用部署在反向代理路径前缀下,且代理**剥离**前缀后转发给 vext。
467+
* 此时 vext 内部路由是 `/openapi.json`,但浏览器必须通过 `/admin/openapi.json` 访问。
468+
* 如果不配置此字段,Scalar HTML 里会写死 `/openapi.json`(绝对路径),
469+
* 浏览器会请求 `https://example.com/openapi.json`(丢失代理前缀)导致 404。
470+
*
471+
* @example
472+
* ```typescript
473+
* // Nginx: /admin/* → vext(剥离 /admin 前缀)
474+
* // vext 路由注册在 /openapi.json
475+
* // 浏览器访问 /admin/openapi.json → Nginx 剥离 → vext /openapi.json ✅
476+
* {
477+
* specPath: '/openapi.json', // vext 内部路由
478+
* specPublicPath: '/admin/openapi.json', // Scalar HTML 引用地址
479+
* }
480+
* ```
481+
*/
482+
specPublicPath?: string;
483+
460484
/** 文档页面路径 @default '/docs' */
461485
docsPath?: string;
462486

src/types/app.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,27 @@ export interface VextOpenAPIConfig {
404404
docsPath?: string;
405405
/** OpenAPI JSON 路径(默认 '/openapi.json') */
406406
jsonPath?: string;
407+
/**
408+
* OpenAPI JSON 的公开访问路径(用于 Scalar HTML 中引用 spec 的 URL)
409+
*
410+
* 仅影响 Scalar HTML 里 `url` 字段的值,**不影响** vext 内部路由注册路径。
411+
* 未设置时默认与 `jsonPath` 相同。
412+
*
413+
* **使用场景**:应用部署在反向代理路径前缀下,且代理**剥离**前缀后转发给 vext。
414+
* 此时 vext 内部路由是 `/openapi.json`,但浏览器必须通过 `/admin/openapi.json` 访问。
415+
* 如果不配置此字段,Scalar HTML 里会写死 `/openapi.json`(绝对路径),
416+
* 浏览器会请求 `https://example.com/openapi.json`(丢失代理前缀)导致 404。
417+
*
418+
* @example
419+
* ```typescript
420+
* // Nginx: /admin/* → vext(剥离 /admin 前缀)
421+
* openapi: {
422+
* jsonPath: '/openapi.json', // vext 内部路由
423+
* jsonPublicPath: '/admin/openapi.json', // Scalar HTML 引用地址
424+
* }
425+
* ```
426+
*/
427+
jsonPublicPath?: string;
407428

408429
/** 联系信息 */
409430
contact?: { name?: string; email?: string; url?: string };

website/docs/api/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ OpenAPI 文档自动生成配置。
434434
| `description` | `string` | `undefined` | 文档描述 |
435435
| `docsPath` | `string` | `'/docs'` | Scalar 文档路径 |
436436
| `jsonPath` | `string` | `'/openapi.json'` | OpenAPI JSON 路径 |
437+
| `jsonPublicPath` | `string` |`jsonPath` | OpenAPI spec 的公开访问路径(仅影响 Scalar HTML 中引用 spec 的 URL,不影响路由注册)。用于反向代理剥离前缀场景,[详见指南](/guide/openapi#反向代理路径前缀场景) |
437438
| `contact` | `object` | `undefined` | 联系信息 |
438439
| `license` | `object` | `undefined` | 许可证信息 |
439440
| `servers` | `array` | `undefined` | 服务器地址列表 |

0 commit comments

Comments
 (0)