使用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

json
{
  "proxyUrl": "https://my-server.com/proxy",
  "targetBase": "https://api.openai.com",
  "headers": {
    "x-proxy-key": "Bearer sk-123"
  }
}

2. 前端中间件实现

基于你已有的 registerProxy 劫持框架,我们编写如下解析逻辑:

TypeScript

javascript
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

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

javascript
function createProxyUrl(config, apiPath) {
    const base64 = btoa(JSON.stringify(config));
    return `proxy://${base64}@${apiPath}`;
}

发起请求

JavaScript

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. 总结

该方案的优势在于:

  1. 高度封装:业务代码无需关心代理服务器的具体实现。

  2. 兼容性强:Base64 避免了 URL 特殊字符带来的解析噩梦。

  3. 动态性:可以随时在前端通过修改 config 来切换不同的代理节点或目标 API。

注意:由于 Base64 编码会增加字符串体积,且 URL 长度在不同浏览器中有限制(如 Chrome 限制为 2MB),本方案适用于配置信息中等的场景。


Demo

html
<!-- 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>