零成本解决前端跨域难题:Cloudflare Workers 代理 Coze API 实战教程

0 阅读4分钟

本文简介

本文将介绍一种通过 Cloudflare 的 workers 功能进行代理请求的方法。

  • 用途:对那些我们无法控制服务端,又不得不通过前端调用,还不支持跨域请求的 API 接口,我们只能通过服务器代理转发。这时候就可以使用 Cloudflare 的 workers 进行代理。
  • 优点:Cloudflare 的 workers 功能开通免费,每日有 10 万次免费调用额度,免费提供域名(可以绑定自己的域名)。
  • 缺点:Cloudflare 的 workers 请求需要使用魔法上网。
  • 我的用途:我使用 coze(扣子)编排的工作流想通过前端直接调用,但 coze(扣子)的工作流不支持跨域。我的网站部署在 Github Pages 中,我又不想买服务器,所以采取了这种办法。
  • 备注:以下示例都是围绕 coze(扣子)工作流转发/代理进行介绍。

准备工作

  1. 注册 cloudflare 账户, cloudflare 支持邮箱、谷歌账号、苹果账号以及 Github 账号注册和登录。备注:此处不需要任何资质、备案信息
  2. 创建 workers,路径:计算和 AI -> Workers 和 Pages -> 创建应用程序 -> 从 Hello World! 开始
  3. 打开上一步创建的 workers,在右上角选择 编辑代码

监听/拦截请求

Cloudflare 的 workers 请求有统一的模版,当 workers 监听到请求,会自行调用 fetch 函数,只需在 fetch 中编写逻辑代码即可,示例如下。

  • 参数介绍:
    • request:Fetch API 的 Request 对象。包含 Http 的所有请求信息,如:URL、请求方法、请求头、请求体等
    • env:绑定到当前 workers 中的环境变量,如 coze 的鉴权 token、请求 host 都可以绑定到环境变量中,使用 env 进行访问
      • 绑定路径:打开相应的 workers -> 顶部设置按钮 -> 找到变量和机密模块,添加时名称为变量名称
    • ctx: 执行上下文,此处本文为使用,请需要的朋友自行查询。
  • handleRequest:具体的请求处理逻辑
export default {
  async fetch(request, env, ctx) {
    return handleRequest(request, env);
  }
};

代理转发

此处介绍转发/代理的具体逻辑,也就是 handleRequest 函数。

实现逻辑:

  • cors 返回头设置允许跨域 公有变量,用于复用信息
const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, token',
};
  • 判断请求类型是否为 OPTIONS,对所有 OPTIONS 请求返回 204 并在返回头中设置 cors 允许跨域。备注:OPTIONS 请求为浏览器跨域预检请求。
  // 处理预检请求(OPTIONS)
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        ...corsHeaders,
        'Access-Control-Max-Age': '86400'
      }
    });
  }
  • 由于 coze 的工作流请求为 POST 类型,所以对非 POST 请求返回 405,有不同需求的朋友请自行根据需求编写。
  // 只允许 POST 请求(Coze 工作流使用 POST)
  if (request.method !== 'POST') {
    return new Response(JSON.stringify({ error: '请求方式不支持,仅支持 POST' }), {
      status: 405,
      headers: {
        'Content-Type': 'application/json',
        ...corsHeaders
      }
    });
  }
  • 从 env 获取 coze host 以及 token,如果不存在则返回 500,并提示凭证错误
  // Coze API 配置
  const COZE_BASE_URL = env.COZE_BASE_URL;
  const COZE_TOKEN = env.COZE_TOKEN;
  // 检查环境变量
if (!COZE_BASE_URL || !COZE_TOKEN) {
  return new Response(JSON.stringify({
    error: '凭证错误',
    message: 'COZE_BASE_URL/COZE_TOKEN 环境变量不存在'
  }), {
    status: 500,
    headers: {
      'Content-Type': 'application/json',
      ...corsHeaders
    }
  });
}
  • 从 request 中解析出 URL 路径、query 参数,与 coze 工作流域名拼接为完整的 URL。备注:此处也可以对 URL 设置白名单过滤,只放行白名单中的 URL,示例中未开启。
    // 解析请求 URL,获取路径和查询参数
    const url = new URL(request.url);
    const path = url.pathname; // 例如: /v1/workflow/run
    const searchParams = url.search;
    // 安全验证:只允许特定的 Coze API 路径(防止滥用)
    const allowedPaths = [
      '/v1/workflow/run',
      '/v1/bot/chat', 
      '/v3/chat',
      '/v1/conversation/create',
      '/v1/conversation/message/create'
    ];
    
    // 如果路径不在白名单中,返回错误(可选)
    /*
    if (!allowedPaths.some(allowed => path.startsWith(allowed))) {
      return new Response(JSON.stringify({
        error: 'Invalid Path',
        message: `Path ${path} is not allowed. Allowed paths: ${allowedPaths.join(', ')}`
      }), {
        status: 403,
        headers: {
          'Content-Type': 'application/json',
          ...corsHeaders
        }
      });
    }
    */
    // 构建完整的 Coze API URL
    const apiUrl = `${COZE_BASE_URL}${path}${searchParams}`;
    
    console.log(`Proxying request to: ${apiUrl}`); // 在 Cloudflare Logs 中查看
  • 从 request 中解析出 headers、body。
    // 构建请求头
    const headers = new Headers();
    headers.set('Authorization', `Bearer ${COZE_TOKEN}`);
    
    // 保留客户端的关键头部,如果请求头存在 Authorization 则使用请求头中的 Authorization
    for (const [key, value] of request.headers.entries()) {
      const lowerKey = key.toLowerCase();
      if (['Authorization', 'content-type', 'accept', 'user-agent', 'token'].includes(lowerKey)) {
        headers.set(key, value);
      }
    }

    // 获取请求体
    const requestBody = await request.text();
  • 请求 coze 工作流,将工作流返回信息包装为 Response 对象进行返回。备注:此处返回时返回头需要和预检请求 OPTIONS 一样设置 CORS 允许跨域。

    // 转发请求
    const response = await fetch(apiUrl, {
      method: 'POST',
      headers: headers,
      body: requestBody
    });

    // 返回响应
    const responseBody = await response.text();
    
    return new Response(responseBody, {
      status: response.status,
      statusText: response.statusText,
      headers: {
        'Content-Type': response.headers.get('Content-Type') || 'application/json',
        ...corsHeaders
      }
    });

完整源码

export default {
  async fetch(request, env, ctx) {
    return handleRequest(request, env);
  }
};

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, token',
};

async function handleRequest(request, env) {
  // 处理预检请求(OPTIONS)
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        ...corsHeaders,
        'Access-Control-Max-Age': '86400'
      }
    });
  }

  // 只允许 POST 请求(Coze 工作流使用 POST)
  if (request.method !== 'POST') {
    return new Response(JSON.stringify({ error: '请求方式不支持,仅支持 POST' }), {
      status: 405,
      headers: {
        'Content-Type': 'application/json',
        ...corsHeaders
      }
    });
  }

  // Coze API 配置
  const COZE_BASE_URL = env.COZE_BASE_URL;
  const COZE_TOKEN = env.COZE_TOKEN;
  // 检查环境变量
  if (!COZE_BASE_URL || !COZE_TOKEN) {
    return new Response(JSON.stringify({
      error: '凭证错误',
      message: 'COZE_BASE_URL/COZE_TOKEN 环境变量不存在'
    }), {
      status: 500,
      headers: {
        'Content-Type': 'application/json',
        ...corsHeaders
      }
    });
  }

  try {

    // 解析请求 URL,获取路径和查询参数
    const url = new URL(request.url);
    const path = url.pathname; // 例如: /v1/workflow/run
    const searchParams = url.search;

    // 安全验证:只允许特定的 Coze API 路径(防止滥用)
    const allowedPaths = [
      '/v1/workflow/run',
      '/v1/bot/chat', 
      '/v3/chat',
      '/v1/conversation/create',
      '/v1/conversation/message/create'
    ];
    
    // 如果路径不在白名单中,返回错误(可选)
    /*
    if (!allowedPaths.some(allowed => path.startsWith(allowed))) {
      return new Response(JSON.stringify({
        error: 'Invalid Path',
        message: `Path ${path} is not allowed. Allowed paths: ${allowedPaths.join(', ')}`
      }), {
        status: 403,
        headers: {
          'Content-Type': 'application/json',
          ...corsHeaders
        }
      });
    }
    */

    // 构建完整的 Coze API URL
    const apiUrl = `${COZE_BASE_URL}${path}${searchParams}`;
    
    console.log(`Proxying request to: ${apiUrl}`); // 在 Cloudflare Logs 中查看

    // 构建请求头
    const headers = new Headers();
    headers.set('Authorization', `Bearer ${COZE_TOKEN}`);
    
    // 保留客户端的关键头部,如果请求头存在 Authorization 则使用请求头中的 Authorization
    for (const [key, value] of request.headers.entries()) {
      const lowerKey = key.toLowerCase();
      if (['Authorization', 'content-type', 'accept', 'user-agent', 'token'].includes(lowerKey)) {
        headers.set(key, value);
      }
    }

    // 获取请求体
    const requestBody = await request.text();
    
    // 转发请求
    const response = await fetch(apiUrl, {
      method: 'POST',
      headers: headers,
      body: requestBody
    });

    // 返回响应
    const responseBody = await response.text();
    
    return new Response(responseBody, {
      status: response.status,
      statusText: response.statusText,
      headers: {
        'Content-Type': response.headers.get('Content-Type') || 'application/json',
        ...corsHeaders
      }
    });

  } catch (error) {
    console.error('Proxy error:', error);
    return new Response(JSON.stringify({
      error: 'Proxy error',
      message: error.message
    }), {
      status: 500,
      headers: {
        'Content-Type': 'application/json',
        ...corsHeaders
      }
    });
  }
}