From b1be887287b6376413db05a0bbe82e7a7a45a3fb Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 15 Mar 2026 13:41:53 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BB=A3=E7=90=86=20HTTP=20=E5=92=8C=20WebSo?= =?UTF-8?q?cket=20=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atsf_server/web/README.md | 17 ++- atsf_server/web/lib/env/public-env.ts | 26 ++-- atsf_server/web/package.json | 5 +- atsf_server/web/scripts/dev-server.mjs | 161 +++++++++++++++++++++++++ docs/deployment.md | 6 +- 5 files changed, 190 insertions(+), 25 deletions(-) create mode 100644 atsf_server/web/scripts/dev-server.mjs diff --git a/atsf_server/web/README.md b/atsf_server/web/README.md index 2724c491..ec42fd70 100644 --- a/atsf_server/web/README.md +++ b/atsf_server/web/README.md @@ -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/`:测试代码 \ No newline at end of file +* `tests/`:测试代码 diff --git a/atsf_server/web/lib/env/public-env.ts b/atsf_server/web/lib/env/public-env.ts index 3b62a710..921e60fc 100644 --- a/atsf_server/web/lib/env/public-env.ts +++ b/atsf_server/web/lib/env/public-env.ts @@ -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, +}; diff --git a/atsf_server/web/package.json b/atsf_server/web/package.json index 6d607725..f67f9291 100644 --- a/atsf_server/web/package.json +++ b/atsf_server/web/package.json @@ -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", diff --git a/atsf_server/web/scripts/dev-server.mjs b/atsf_server/web/scripts/dev-server.mjs new file mode 100644 index 00000000..5e30f4af --- /dev/null +++ b/atsf_server/web/scripts/dev-server.mjs @@ -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}`); +}); diff --git a/docs/deployment.md b/docs/deployment.md index b03b50c9..8d73f9d1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 托管为准