[优化] 更新开发环境配置,统一代理 HTTP 和 WebSocket 请求

This commit is contained in:
ryan
2026-03-15 13:41:53 +08:00
parent 6e1eac2c86
commit b1be887287
5 changed files with 190 additions and 25 deletions
+11 -6
View File
@@ -2,14 +2,17 @@
ATSFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。
## 常用命令
## 常用命令
```shell
corepack enable
pnpm install
# 本地开发
pnpm dev
# 本地开发(3001 前端同源代理后端 HTTP/WS)
pnpm dev
# 生产模式启动
pnpm start
# 类型检查
pnpm typecheck
@@ -28,8 +31,10 @@ pnpm build
* 构建采用 Next.js 静态导出模式。
* `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。
* 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
* 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。
* 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
* 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。
* `pnpm dev` 会以开发模式启动 `http://127.0.0.1:3001`,并通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认是 `http://127.0.0.1:3000`。
* `pnpm start` 用于启动已构建产物,对应 Next 生产模式。
## 目录约定
@@ -37,4 +42,4 @@ pnpm build
* `features/`:业务模块
* `components/`:复用组件
* `lib/`:请求、环境变量、工具与常量
* `tests/`:测试代码
* `tests/`:测试代码
+13 -13
View File
@@ -1,14 +1,14 @@
import { z } from 'zod';
const publicEnvSchema = z.object({
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
});
const rawEnv = {
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
};
const publicEnvSchema = z.object({
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
});
const rawEnv = {
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
};
const parsedEnv = publicEnvSchema.parse(rawEnv);
@@ -20,7 +20,7 @@ function normalizeBaseUrl(value: string) {
return value.replace(/\/+$/, '');
}
export const publicEnv = {
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
};
export const publicEnv = {
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
};
+2 -3
View File
@@ -4,11 +4,10 @@
"private": true,
"packageManager": "pnpm@10.6.2",
"scripts": {
"dev": "next dev",
"dev": "NEXT_DEV_BACKEND_URL=${NEXT_DEV_BACKEND_URL:-http://127.0.0.1:3000} node scripts/dev-server.mjs",
"build": "next build",
"postbuild": "node scripts/prepare-static-export.mjs",
"start": "NEXT_DEV_PROXY=true next dev -p 3001",
"start:prod": "next start",
"start": "next start",
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
+161
View File
@@ -0,0 +1,161 @@
import http from 'node:http';
import https from 'node:https';
import next from 'next';
const port = Number.parseInt(process.env.PORT ?? '3001', 10);
const hostname = process.env.HOSTNAME || '127.0.0.1';
const backendBaseUrl = new URL(
(process.env.NEXT_DEV_BACKEND_URL || 'http://127.0.0.1:3000').replace(/\/+$/, ''),
);
const app = next({
dev: true,
hostname,
port,
});
let handle;
let handleUpgrade;
function isApiRequest(url = '') {
return url.startsWith('/api/');
}
function createProxyRequestOptions(req, targetUrl) {
const headers = {
...req.headers,
host: targetUrl.host,
};
if (headers.origin) {
headers.origin = targetUrl.origin;
}
return {
protocol: targetUrl.protocol,
hostname: targetUrl.hostname,
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
method: req.method,
path: `${targetUrl.pathname}${targetUrl.search}`,
headers,
};
}
function writeProxyError(res, error) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
success: false,
message: `开发代理连接后端失败: ${error.message}`,
}),
);
}
function proxyHttpRequest(req, res) {
const targetUrl = new URL(req.url || '/', backendBaseUrl);
const requestImpl = targetUrl.protocol === 'https:' ? https.request : http.request;
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl), (proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.statusMessage, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (error) => {
if (!res.headersSent) {
writeProxyError(res, error);
return;
}
res.destroy(error);
});
req.pipe(proxyReq);
}
function formatUpgradeResponseHeaders(statusCode, statusMessage, headers) {
const lines = [`HTTP/1.1 ${statusCode} ${statusMessage}`];
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
lines.push(`${key}: ${item}`);
}
continue;
}
lines.push(`${key}: ${value}`);
}
lines.push('', '');
return lines.join('\r\n');
}
function proxyWebSocketUpgrade(req, socket, head) {
const targetUrl = new URL(req.url || '/', backendBaseUrl);
const requestImpl = targetUrl.protocol === 'https:' ? https.request : http.request;
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl));
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
socket.write(
formatUpgradeResponseHeaders(
proxyRes.statusCode || 101,
proxyRes.statusMessage || 'Switching Protocols',
proxyRes.headers,
),
);
if (head.length > 0) {
proxySocket.write(head);
}
if (proxyHead.length > 0) {
socket.write(proxyHead);
}
proxySocket.pipe(socket).pipe(proxySocket);
proxySocket.on('error', () => socket.destroy());
socket.on('error', () => proxySocket.destroy());
});
proxyReq.on('response', (proxyRes) => {
socket.write(
formatUpgradeResponseHeaders(
proxyRes.statusCode || 502,
proxyRes.statusMessage || 'Bad Gateway',
proxyRes.headers,
),
);
proxyRes.pipe(socket);
});
proxyReq.on('error', () => {
socket.destroy();
});
proxyReq.end();
}
await app.prepare();
handle = app.getRequestHandler();
handleUpgrade = app.getUpgradeHandler();
const server = http.createServer((req, res) => {
if (isApiRequest(req.url)) {
proxyHttpRequest(req, res);
return;
}
handle(req, res);
});
server.on('upgrade', (req, socket, head) => {
if (isApiRequest(req.url)) {
proxyWebSocketUpgrade(req, socket, head);
return;
}
handleUpgrade(req, socket, head);
});
server.listen(port, hostname, () => {
console.log(`ATSFlare web dev server listening on http://${hostname}:${port}`);
console.log(`Proxying /api/* and websocket upgrades to ${backendBaseUrl.href}`);
});
+3 -3
View File
@@ -47,13 +47,13 @@ pnpm build
cd atsf_server/web
corepack enable
pnpm install
pnpm start
pnpm dev
```
说明:
* `pnpm start` 默认以 Next.js 开发模式启动,监听 `http://127.0.0.1:3001`
* 开发服务器会把 `/api/*` 请求反向代理到 `http://127.0.0.1:3000`
* `pnpm dev` 默认以 Next.js 开发模式启动,监听 `http://127.0.0.1:3001`
* 开发服务器会通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认指向 `http://127.0.0.1:3000`
* 如需改后端地址,可在启动前设置 `NEXT_DEV_BACKEND_URL`
* 这种模式用于本地热更新开发;正式运行和交付仍以 `pnpm build` 后由 Go Server 托管为准