# node-proxy
**Repository Path**: scedm/node-proxy
## Basic Information
- **Project Name**: node-proxy
- **Description**: 通过一个 Node.js 反向代理(端口 8199),将两个管理应用暴露在**同一源**(`http://127.0.0.1:8199`)下,根据 URL 路径前缀分发到不同应用
- **Primary Language**: Unknown
- **License**: AGPL-3.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-05-16
- **Last Updated**: 2026-06-03
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 同源反向代理架构
## 概述
通过一个 Node.js 反向代理(端口 8199),将两个 Vben Admin 应用暴露在**同一源**(`http://127.0.0.1:8199`)下,根据 URL 路径前缀分发流量:
| 路径 | 目标应用 | 后端端口 | 代码路径 |
|------|---------|---------|---------|
| `/` 及其他路径 | App A(统一门户) | `https://127.0.0.1:3100` | `~/vue-vben-admin` |
| `/heathdata` 前缀 | App B(跳转后的应用) | `https://127.0.0.1:3101` | `~/vue-vben-admin2\vue-vben-admin` |
### 架构图
```
浏览器 → http://127.0.0.1:8199/
│
▼
Node.js 反向代理 (proxy.js)
│
┌───────┴───────┐
▼ ▼
/ → 3100 /heathdata → 3101
(App A) (App B, VITE_PUBLIC_PATH=/heathdata/)
```
### 可视化架构图
`diagrams/` 目录包含 4 种视觉风格的架构图,涵盖代理路由、WebSocket HMR、Token 共享:
| 风格 | 文件 | 预览 |
|------|------|------|
| 暗黑极客风 | `diagrams/architecture-dark-hacker.svg` | 深色背景,Neon 配色,等宽字体 |
| 扁平图标风 | `diagrams/architecture-flat-icon.svg` | 白底,语义箭头,分层布局 |
| 工程蓝图风 | `diagrams/architecture-blueprint.svg` | 深蓝底,网格线,青色描边 |
| Claude 官方风格 | `diagrams/architecture-claude-official.svg` | 暖奶油色背景,Anthropic 品牌色 |
PNG 版本(1920px 宽)同步生成,可直接用于文档和演示。
---
## 1. 代理服务器
**文件**: `proxy.js` — 完整代码
```js
// ============================================================
// Node.js 反向代理服务器
// 功能:根据请求路径将流量分发到不同的后端服务
// 路径 /heathdata -> 后端 3101 端口(App B,跳转后的应用)
// 其他路径 -> 后端 3100 端口(App A,统一门户)
// ============================================================
const http = require("http");
const httpProxy = require("http-proxy");
// 创建代理服务器实例
const proxy = httpProxy.createProxyServer({
changeOrigin: true, // 修改请求的 Host 为目标的 Host
});
// 全局错误处理
// 注意:WebSocket 错误时 res 可能没有 writeHead 方法,需要做类型判断
proxy.on("error", (err, req, res) => {
console.error("代理出错:", err.message);
if (typeof res.writeHead === 'function') {
res.writeHead(502, { "Content-Type": "text/plain" });
res.end("无法连接到后端服务。");
}
});
// HTTP 请求处理
// 根据路径前缀分发流量到不同的后端应用
const server = http.createServer((req, res) => {
if (req.url.startsWith("/heathdata")) {
// 剥离 /heathdata 前缀,因为 App B 本身不识别此前缀
// 例如: /heathdata/#/dashboard -> /#/dashboard
req.url = req.url.replace(/^\/heathdata/, "");
console.log(`转发请求: ${req.url} -> 3101 (App B)`);
proxy.web(req, res, { target: "https://127.0.0.1:3101/", secure: false });
} else {
// 其他请求转发到 App A(统一门户)
console.log(`转发请求: ${req.url} -> 3100 (App A)`);
proxy.web(req, res, { target: "https://127.0.0.1:3100/", secure: false });
}
});
// WebSocket 升级请求处理
// Vite HMR 使用 WebSocket,需要同步转发 upgrade 请求
server.on("upgrade", (req, socket, head) => {
if (req.url.startsWith("/heathdata")) {
// 同样需要剥离前缀后转发到 App B
req.url = req.url.replace(/^\/heathdata/, "");
proxy.ws(req, socket, head, { target: "https://127.0.0.1:3101/", secure: false });
} else {
// 其他 WebSocket 连接转发到 App A
proxy.ws(req, socket, head, { target: "https://127.0.0.1:3100/", secure: false });
}
});
// 启动服务
server.listen(8199, () => {
console.log("Node 反向代理已启动,监听端口 8199");
});
```
### 关键配置
- `changeOrigin: true` — 修改 Host 头为目标地址
- `secure: false` — 跳过后端自签名 SSL 证书验证
- 错误处理中判断 `typeof res.writeHead === 'function'` — 避免 WebSocket 错误时调用 HTTP 响应方法导致崩溃
---
## 2. 同源 Token 共享
### 原理
两个应用通过同一个代理访问,浏览器视为**同一源**(`http://127.0.0.1:8199`),因此共享 `localStorage`。App B 启动时直接从 localStorage 读取 App A 写入的 token。
### App B 读取逻辑
**文件**: `src/main.ts` — `syncTokenFromAppA()`
```ts
function syncTokenFromAppA() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('VUE_VBEN_ADMIN__') && key.endsWith('__COMMON__LOCAL__KEY__')) {
const raw = localStorage.getItem(key);
const decrypted = enableStorageEncryption
? new AesEncryption({ key: cacheCipher.key, iv: cacheCipher.iv }).decryptByAES(raw)
: raw;
const data = JSON.parse(decrypted);
const token = data?.value?.TOKEN__?.value;
if (token) {
setAuthCache(TOKEN_KEY, token);
}
break;
}
}
}
```
### Token 存储结构
App A 登录后,token 存储在 localStorage,key 格式为:
```
{SHORT_NAME}__{MODE}__{VERSION}__COMMON__LOCAL__KEY__
```
例如:`VUE_VBEN_ADMIN__DEVELOPMENT__2.9.13__COMMON__LOCAL__KEY__`
值经过 AES 加密(生产环境),解密后结构:
```json
{
"value": {
"TOKEN__": { "value": "eyJhbGciOiJIUz..." },
"USER__INFO__": { "value": { ... } }
}
}
```
### 关键依赖
| 模块 | 路径 |
|------|------|
| `setAuthCache` | `src/utils/auth/index.ts` |
| `TOKEN_KEY` | `src/enums/cacheEnum.ts` → `'TOKEN__'` |
| `AesEncryption` | `src/utils/cipher.ts` |
| `cacheCipher` | `src/settings/encryptionSetting.ts` → key=`_11111000001111@`, iv=`@11111000001111_` |
| `enableStorageEncryption` | `src/settings/encryptionSetting.ts` → 生产环境加密 |
### 调用时机
在 `bootstrap()` 中,`setupRouter(router)` 之后、`setupRouterGuard(router)` 之前调用,确保路由守卫执行时 token 已就绪:
```ts
async function bootstrap() {
// ... 初始化 store、i18n、路由 ...
setupRouter(app);
syncTokenFromAppA(); // ← 同步 token
setupRouterGuard(router); // ← 路由守卫使用 token
// ...
}
```
---
## 3. 路径前缀配置(App B)
### VITE_PUBLIC_PATH
**文件**: `.env.development`
```env
VITE_PUBLIC_PATH = /heathdata/
```
此配置让 Vite 在生成所有资源路径时自动添加 `/heathdata/` 前缀,确保代理能正确路由。
### 路由 Base
**文件**: `src/router/index.ts`
```ts
history: createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH),
```
路由使用 Hash 模式,base 路径同样设置为 `/heathdata/`,因此 URL 格式为:
```
http://127.0.0.1:8199/heathdata/#/dashboard
http://127.0.0.1:8199/heathdata/#/system/user
```
---
## 4. Vite 开发服务器配置
### Vite 开发服务器配置
**文件**: `vite.config.ts`(两个应用相同配置,端口根据 `VITE_PORT` 环境变量区分)
```ts
server: {
https: true, // HTTPS 访问 https://localhost:5173
host: true, // 可通过局域网 IP 访问
port: 5173, // Vite 端口(实际值由 VITE_PORT 环境变量指定)
hmr: {
protocol: 'ws', // 强制使用 ws:// 而非 wss://(代理处理 HTTP,避免 SSL 证书问题)
clientPort: 8199, // HMR WebSocket 客户端连接到代理端口 8199
},
fs: {
allow: ['..'], // 允许访问项目目录外的文件
},
proxy: createProxy(VITE_PROXY), // 开发环境 API 代理(从 .env 加载)
}
```
> **HMR 关键点**: Vite 默认根据页面协议(HTTPS)生成 `wss://` 的 WebSocket 连接,但代理处理的是 HTTP。强制设置 `protocol: 'ws'` 生成 `ws://` 连接,避免 WebSocket 握手失败导致页面反复刷新。
### HTTPS
两个 Vite 开发服务器均启用 HTTPS(使用 Vite 自签名证书),代理通过 `secure: false` 跳过验证。
### 端口
| 应用 | Vite 端口 | 代理路径 |
|------|-----------|---------|
| App A | 3100 | `/` |
| App B | 3101 | `/heathdata` |
| 代理 | 8199 | 统一入口 |
---
## 5. 应用间导航
**文件**: `src/views/dashboard/workbench/components/WorkbenchHeader.vue`(App A)
在 App A 的工作台页面添加跳转链接,使用相对路径导航到 App B:
```vue
点击这里
```
### URL 相对路径解析(以 `http://127.0.0.1:8199/#/dashboard/workbench` 为例)
| 写法 | 最终 URL | 说明 |
|------|---------|------|
| `'./heathdata'` | `http://127.0.0.1:8199/heathdata` | 当前目录,依赖当前路径上下文 |
| `'/heathdata'` | `http://127.0.0.1:8199/heathdata` | **推荐** — 从根目录开始,无歧义 |
| `'http://127.0.0.1:8199/heathdata'` | 同上 | 硬编码 host 和端口,不灵活 |
---
## 6. 启动顺序
```bash
# 1. 启动 Node.js 代理
cd ~/proxy
node proxy.js
# 2. 启动 App B(3101 端口)
cd ~/vue-vben-admin2\vue-vben-admin
pnpm dev
# 3. 启动 App A(3100 端口)
cd ~/vue-vben-admin
pnpm dev
```
访问 `http://127.0.0.1:8199/` → App A,登录后在 App A 工作台点击「点击这里」或直接访问 `http://127.0.0.1:8199/heathdata` → App B。
---
## 7. 修改的文件清单
| 文件 | 修改内容 |
|------|---------|
| `~/proxy\proxy.js` | 路径路由、WebSocket 转发、错误处理 |
| `~/vue-vben-admin2\vue-vben-admin\.env.development` | `VITE_PUBLIC_PATH = /heathdata/` |
| `~/vue-vben-admin2\vue-vben-admin\src\main.ts` | 添加 `syncTokenFromAppA()` |
| `~/vue-vben-admin2\vue-vben-admin\src\router\index.ts` | `createWebHashHistory(VITE_PUBLIC_PATH)` |
| `~/vue-vben-admin\vite.config.ts` | HMR `protocol: 'ws'`, `clientPort: 8199` |
| `~/vue-vben-admin2\vue-vben-admin\vite.config.ts` | HMR `protocol: 'ws'`, `clientPort: 8199` |
| `~/vue-vben-admin\src\views\dashboard\workbench\components\WorkbenchHeader.vue` | App B 导航链接 |
## 其他方式. 共享鉴权认证信息
### 上传认证信息
```
(function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(event) {
try {
// 解析合并后的 JSON 数据
var data = JSON.parse(event.target.result);
// 恢复 localStorage 数据
if (data.localStorage) {
for (var key in data.localStorage) {
localStorage.setItem(key, data.localStorage[key]);
}
}
// 恢复 sessionStorage 数据
if (data.sessionStorage) {
for (var key in data.sessionStorage) {
sessionStorage.setItem(key, data.sessionStorage[key]);
}
}
alert('✅ 数据导入成功!请刷新页面以使数据生效。');
} catch (error) {
alert('❌ 导入失败:JSON 文件格式不正确');
console.error('解析错误:', error);
}
};
reader.readAsText(file);
};
input.click();
})();
```
### 下载认证信息
```
(function() {
// 辅助函数:安全地提取 storage 数据转为普通对象
function getStorageData(storage) {
var data = {};
for (var i = 0; i < storage.length; i++) {
var key = storage.key(i);
data[key] = storage.getItem(key);
}
return data;
}
// 1. 获取并组合两端数据
var combinedData = {
localStorage: getStorageData(localStorage),
sessionStorage: getStorageData(sessionStorage)
};
var dataString = JSON.stringify(combinedData, null, 2); // 格式化 JSON 以便阅读
// 2. 创建 Blob 对象并生成下载链接
var blob = new Blob([dataString], { type: 'application/json' });
var url = URL.createObjectURL(blob);
// 3. 创建临时 a 标签触发下载
var a = document.createElement('a');
a.href = url;
a.download = 'browser_storage_backup.json';
document.body.appendChild(a);
a.click();
// 4. 清理临时元素
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('✅ Storage 数据备份下载成功!');
})();
```