使用Relayr代理协议URL
在前端开发中,我们经常遇到跨域、需要动态更换 API 代理或是在请求头中注入鉴权信息的需求。本文将介绍一种通过 Fetch 劫持 结合 自定义伪协议 的方案,实现一套优雅且灵活的代理中间件。
在Cent中,所有允许输入自定义URL的地方都支持Relayr协议自定义代理,但是你需要自己实现一个支持该协议的后端服务,可以通过Cloudflare Workers或者Supabase Edge Function部署。
1. 协议设计
传统的 URL 传参容易受到特殊字符(如 JSON 中的引号、大括号)的影响,导致解析失败。我们采用 Base64 编码配置 的方案:
协议格式
proxy://<base64_config>@<target_path_suffix>
proxy://: 协议头,用于中间件识别。<base64_config>: 包含代理服务器地址、目标基准地址、额外 Header 的 JSON 对象,并经过 Base64 编码。@: 分隔符。<target_path_suffix>: 具体的 API 路径后缀。
数据结构示例
编码前的 JSON 配置:
JSON
{
"proxyUrl": "https://my-server.com/proxy",
"targetBase": "https://api.openai.com",
"headers": {
"x-proxy-key": "Bearer sk-123"
}
}
2. 前端中间件实现
基于你已有的 registerProxy 劫持框架,我们编写如下解析逻辑:
TypeScript
const proxyMiddleware: Handler = async (url, options, next) => {
const urlStr = url.toString();
// 1. 识别协议
if (!urlStr.startsWith('proxy://')) {
return next(url, options);
}
try {
// 2. 拆分 Base64 配置与路径后缀
const mainPart = urlStr.slice(8); // 移除 'proxy://'
const [configBase64, ...pathParts] = mainPart.split('@');
const targetPathSuffix = pathParts.join('@'); // 防止路径中也带有 @
// 3. 解码并解析配置
const configJson = JSON.parse(atob(configBase64));
const { proxyUrl, targetBase, headers: extraHeaders } = configJson;
// 拼接最终目标地址
const finalTargetUrl = `${targetBase}${targetPathSuffix}`;
// 4. 转换请求参数
const newOptions: RequestInit = {
...options,
method: 'POST', // 代理请求统一转为 POST 以便在 Body 中携带 target
headers: {
...options.headers,
...extraHeaders,
'Content-Type': 'application/json'
},
body: JSON.stringify({
target: finalTargetUrl,
payload: options.body ?
(typeof options.body === 'string' ? JSON.parse(options.body) : options.body)
: null
})
};
console.log(`[Proxy] Forwarding to: ${finalTargetUrl}`);
return next(proxyUrl, newOptions);
} catch (err) {
console.error("[Proxy Error]", err);
return next(url, options);
}
};
3. 代理后端实现 (Node.js 示例)
后端代理服务器需要接收 POST 请求,解析其中的 target 字段,并携带请求头转发。
JavaScript
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
app.post('/proxy', async (req, res) => {
const { target, payload } = req.body;
// 获取前端注入的额外 Header (例如 x-proxy-key)
const proxyHeaders = { ...req.headers };
// 移除宿主信息,防止目标服务器校验失败
delete proxyHeaders.host;
try {
console.log(`Forwarding request to: ${target}`);
const response = await axios({
method: 'POST', // 或根据逻辑透传原 method
url: target,
data: payload,
headers: proxyHeaders,
timeout: 10000
});
res.status(response.status).send(response.data);
} catch (error) {
const status = error.response ? error.response.status : 500;
res.status(status).send(error.message);
}
});
app.listen(3000, () => console.log('Proxy Server running on port 3000'));
4. 如何使用
在业务代码中,你可以通过工具函数生成这个复合 URL:
生成器工具
JavaScript
function createProxyUrl(config, apiPath) {
const base64 = btoa(JSON.stringify(config));
return `proxy://${base64}@${apiPath}`;
}
发起请求
JavaScript
const config = {
proxyUrl: "http://localhost:3000/proxy",
targetBase: "https://api.openai.com",
headers: { "Authorization": "Bearer TOKEN" }
};
// 实际请求地址:proxy://ey...@{config}@/v1/chat/completions
fetch(createProxyUrl(config, '/v1/chat/completions'), {
method: 'POST',
body: JSON.stringify({ model: 'gpt-3.5-turbo' })
});
5. 总结
该方案的优势在于:
高度封装:业务代码无需关心代理服务器的具体实现。
兼容性强:Base64 避免了 URL 特殊字符带来的解析噩梦。
动态性:可以随时在前端通过修改
config来切换不同的代理节点或目标 API。
注意:由于 Base64 编码会增加字符串体积,且 URL 长度在不同浏览器中有限制(如 Chrome 限制为 2MB),本方案适用于配置信息中等的场景。
Demo
<!-- reactive -->
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; line-height: 1.6; color: #333; }
.card { border: 1px solid #e1e4e8; padding: 24px; border-radius: 12px; box-shadow: 0 8px 24px rgba(149,157,165,0.1); background: white; }
h2 { margin-top: 0; color: #0366d6; font-size: 1.5rem; }
label { display: block; margin-top: 15px; font-weight: 600; font-size: 0.9rem; color: #586069; }
input, textarea { width: 100%; padding: 10px; margin-top: 6px; box-sizing: border-box; border: 1px solid #d1d5da; border-radius: 6px; font-size: 14px; background: #fafbfc; transition: border 0.2s; }
input:focus, textarea:focus { border-color: #0366d6; outline: none; background: white; box-shadow: 0 0 0 3px rgba(3,102,214,0.1); }
button { background: #2ea44f; color: white; border: 1px solid rgba(27,31,35,0.15); padding: 12px; border-radius: 6px; cursor: pointer; margin-top: 24px; width: 100%; font-weight: 600; font-size: 16px; transition: background 0.2s; }
button:hover { background: #2c974b; }
#result-container { margin-top: 24px; display: none; animation: fadeIn 0.3s ease; }
.result-box {
background: #f6f8fa;
padding: 15px;
word-break: break-all;
border: 1px dashed #0366d6;
border-radius: 6px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px;
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.result-box:hover { background: #f0f3f6; }
.result-box::after { content: "点击复制"; position: absolute; right: 8px; top: 4px; font-size: 10px; color: #0366d6; font-weight: bold; opacity: 0.7; }
.copy-tip { color: #28a745; font-size: 12px; font-weight: bold; margin-top: 8px; display: none; text-align: center; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>
<div class="card">
<h2>🔗 Proxy 协议生成器</h2>
<label>代理服务器地址 (Proxy URL)</label>
<input type="text" id="proxyUrl" placeholder="https://my-server.com/proxy" value="https://my-server.com/proxy">
<label>目标 API 根路径 (Target Base)</label>
<input type="text" id="targetBase" placeholder="https://openai.com/api" value="https://openai.com/api">
<label>自定义 Header (JSON)</label>
<textarea id="headers" rows="3">{"x-proxy-key": "Bearer sk-123456"}</textarea>
<button onclick="generateLink()">生成并复制链接</button>
<div id="result-container">
<label>生成的伪协议前缀 (点击下方方框自动复制):</label>
<div class="result-box" id="output" onclick="copyToClipboard()"></div>
<div id="copyTip" class="copy-tip">✅ 已成功复制到剪贴板!</div>
<p style="font-size: 12px; color: #6a737d; margin-top: 12px;">
💡 提示:在代码中调用时,直接拼接具体路径,例如:<br>
<code>fetch(prefix + '/v1/chat/completions')</code>
</p>
</div>
</div>
<script>
function generateLink() {
const proxyUrl = document.getElementById('proxyUrl').value;
const targetBase = document.getElementById('targetBase').value;
const headersStr = document.getElementById('headers').value;
try {
// 校验 JSON 格式
const headers = JSON.parse(headersStr);
const config = { proxyUrl, targetBase, headers };
// 安全的 UTF-8 Base64 编码逻辑
const jsonStr = JSON.stringify(config);
const base64 = btoa(encodeURIComponent(jsonStr).replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode('0x' + p1);
}));
const finalUrl = `proxy://${base64}@`;
// 显示结果
const output = document.getElementById('output');
output.innerText = finalUrl;
document.getElementById('result-container').style.display = 'block';
// 自动触发一次复制
copyToClipboard(false);
} catch (e) {
alert("❌ 错误:JSON 格式不正确,请检查 Header 字段!");
}
}
async function copyToClipboard(showVisualFeedback = true) {
const text = document.getElementById('output').innerText;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
if (showVisualFeedback) {
const tip = document.getElementById('copyTip');
tip.style.display = 'block';
setTimeout(() => {
tip.style.display = 'none';
}, 2000);
}
} catch (err) {
// 降级处理
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
}
</script>