使用Relayr代理协议URL

在前端开发中,我们经常遇到跨域、需要动态更换 API 代理或是在请求头中注入鉴权信息的需求。本文将介绍一种通过 Fetch 劫持 结合 自定义伪协议 的方案,实现一套优雅且灵活的代理中间件。

在Cent中,所有允许输入自定义URL的地方都支持Relayr协议自定义代理,但是你需要自己实现一个支持该协议的后端服务,可以通过Cloudflare Workers或者Supabase Edge Function部署。如果你想直接部署一个代理服务,可以直接查看Demo部分

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

如果你只是想知道如何免费快速地弄出一个代理服务器,用来转发AI服务或者Web DAV给Cent使用,可以直接使用supabase部署一个项目,按照下面的步骤:

1,设置好环境变量RELAYR_TOKENS,用于鉴权,多个用英文逗号分隔。注意绝对不要泄露该token,否则可能被人盗刷。

2,新建一个edge function,命名为proxy(或者任意名称—),部署下面的代码:

javascript
import { Hono } from "https://deno.land/x/hono@v3.11.7/mod.ts";
import { cors } from "https://deno.land/x/hono@v3.11.7/middleware.ts";

/**
 * 核心逻辑:从环境变量获取合法的 Token 列表
 * 请在 Supabase 控制台或使用 CLI 设置 RELAYR_TOKENS
 */
const getValidTokens = () => {
  const tokens = Deno.env.get("RELAYR_TOKENS");
  return tokens ? tokens.split(",") : [];
};

const app = new Hono();

// --- 1. 动态 CORS 中间件 ---
// 解决你遇到的 "Request header field authorization is not allowed" 报错
app.use(
  "*",
  async (c, next) => {
    const requestHeaders = c.req.header("Access-Control-Request-Headers");
    
    const corsMiddleware = cors({
      origin: (origin) => origin, // 允许任何来源,或填写 "http://localhost:5173"
      allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
      // 动态允许前端请求的所有 Header
      allowHeaders: requestHeaders ? requestHeaders.split(",") : ["*"],
      exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
      maxAge: 600,
    });
    
    return corsMiddleware(c, next);
  }
);

// --- 2. 代理主逻辑 ---
app.all("/proxy", async (c) => {
  // 从自定义 Header 获取控制参数
  const targetUrl = c.req.header("x-relayr-target");
  const token = c.req.header("x-relayr-token");
  const overrideMethod = c.req.header("x-relayr-method");

  // A. 校验 Token
  const validTokens = getValidTokens();
  if (!token || !validTokens.includes(token)) {
    return c.json({ error: "Unauthorized: Invalid or missing relayr token." }, 401);
  }

  // B. 校验目标 URL
  if (!targetUrl) {
    return c.json({ error: "Missing 'x-relayr-target' header." }, 400);
  }

  let url: URL;
  try {
    url = new URL(targetUrl);
  } catch {
    return c.json({ error: "Invalid target URL format." }, 400);
  }

  // C. 准备转发的请求头
  const headers = new Headers(c.req.raw.headers);
  // 必须移除代理专用头,否则目标服务器可能因不认识这些头而报错
  headers.delete("x-relayr-token");
  headers.delete("x-relayr-target");
  headers.delete("x-relayr-method");
  // 移除可能引起干扰的标准头
  headers.delete("host");
  headers.delete("origin");
  headers.delete("referer");

  // D. 处理方法和 Body
  const method = (overrideMethod || c.req.method).toUpperCase();
  let body: ArrayBuffer | null = null;
  if (method !== "GET" && method !== "HEAD") {
    body = await c.req.arrayBuffer();
  }

  // E. 执行转发
  try {
    const response = await fetch(url.toString(), {
      method,
      headers,
      body,
      redirect: "follow",
    });

    // F. 处理响应头
    const modifiedHeaders = new Headers(response.headers);
    
    // 移除目标服务器可能返回的安全限制,确保前端能拿到数据
    modifiedHeaders.delete("Content-Security-Policy");
    modifiedHeaders.delete("X-Frame-Options");
    modifiedHeaders.delete("Access-Control-Allow-Origin");
    
    // 重新注入 CORS 头,确保浏览器不会拦截
    modifiedHeaders.set("Access-Control-Allow-Origin", c.req.header("Origin") || "*");

    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: modifiedHeaders,
    });
  } catch (e) {
    console.error(`[Proxy Error]: ${e.message}`);
    return c.json({ error: "Proxy Failed", details: e.message }, 502);
  }
});

// 处理根路径或其他未匹配路径
app.get("/", (c) => c.text("Relayr Proxy is running!"));

// --- 3. 启动服务 ---
Deno.serve(app.fetch);

3,部署完成后,复制proxy edge function对应的url地址,然后将地址填入下面的生成器中,在自定义Header一栏填入。目标根路径填写你需要代理的URL即可,例如你想将openai的API进行代理转发,就可以https://openai.com/api

json
{
"x-relayr-token":"RELAYR_TOKEN"// 替换成你自己定义的RELAYR_TOKENS的某一个
}

4,点击生成并复制链接,将链接直接复制到Cent的地址那一栏即可,这样访问openai地址时,会将实际url通过你的supabase edge function进行转发,这样就可以绕过CORS限制。

生成器如下:

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>