PAYATHON 2026

Vercel 本地 Fetch POST 请求出现 CORS 预检错误

排障赵师傅

结论

浏览器已经指出了失败环节:POST 还没有发出,之前的 OPTIONS 预检请求就收到了非 2xx 响应。

在 vercel.json 中配置 CORS 响应头,只会为响应添加 Header,无法把 404、401、405、500 或重定向改成成功响应。先确认 OPTIONS /api/v1/dummy 的实际状态码,再检查该请求能否绕过鉴权、正确命中函数,并以 204 或其他 2xx 状态结束。

建议直接在 API 函数中处理完整的 CORS 流程,包括 OPTIONS 预检和实际的 POST 响应。

为什么 GET 正常,POST 却失败

页面来源为:

http://127.0.0.1:5500

API 地址为:

http://localhost:4002

协议、主机或端口只要有一项不同,浏览器就会将请求视为跨源请求。127.0.0.1 和 localhost 也属于不同的源。

以下请求头会触发 POST 的预检:

Content-Type: application/json

浏览器会先发送类似的请求:

OPTIONS /api/v1/dummy HTTP/1.1
Origin: http://127.0.0.1:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

浏览器只有在 OPTIONS 返回 2xx 状态并包含匹配的 CORS 响应头后,才会继续发送 POST。

Express 示例之所以能工作,是因为 cors 中间件会自动响应预检请求。根据现有代码,还无法判断 Vercel 函数中的 ensureOptionsPreflight() 是否结束了响应,以及它返回了什么状态,因此应先检查这里。

查看 OPTIONS 的实际响应

在浏览器开发者工具的 Network 面板中筛选 dummy,打开 OPTIONS 请求并检查:

  • Status Code
  • Response Headers
  • Request URL
  • 是否发生 307 或 308 重定向
  • Vercel 本地终端是否收到该请求
  • 函数日志中是否打印了 req method OPTIONS

也可以直接发送请求测试。Windows 环境建议使用 curl.exe,以免调用 PowerShell 的 curl 别名:

curl.exe -i -X OPTIONS "http://localhost:4002/api/v1/dummy" ^
  -H "Origin: http://127.0.0.1:5500" ^
  -H "Access-Control-Request-Method: POST" ^
  -H "Access-Control-Request-Headers: content-type"

macOS 或 Linux 可以执行:

curl -i -X OPTIONS 'http://localhost:4002/api/v1/dummy' \
  -H 'Origin: http://127.0.0.1:5500' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type'

正常响应应接近:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://127.0.0.1:5500
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: Content-Type
Vary: Origin

可以根据状态码继续排查:

  • 404:函数路由没有命中,或者本地开发服务加载的不是预期项目。
  • 401、403:鉴权或其他中间件拦截了预检请求。
  • 405:路由存在,但不接受 OPTIONS。
  • 500:函数导入、初始化或 ensureOptionsPreflight() 执行失败。
  • 307、308:请求被重定向。应直接请求最终地址,并检查斜杠、重写规则和协议。
  • 一直没有响应:预检分支可能没有调用 res.end()、res.send() 或 res.json()。

在 Vercel 函数中完整处理 CORS

可以暂时移除 ensureOptionsPreflight(),改用明确的处理逻辑来验证问题:

import type { VercelRequest, VercelResponse } from '@vercel/node';

const allowedOrigins = new Set([
  'http://127.0.0.1:5500',
  'http://localhost:5500',
]);

export default async function handler(
  req: VercelRequest,
  res: VercelResponse,
) {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  res.setHeader(
    'Access-Control-Allow-Methods',
    'GET, POST, OPTIONS',
  );
  res.setHeader(
    'Access-Control-Allow-Headers',
    'Content-Type, Authorization',
  );

  if (req.method === 'OPTIONS') {
    return res.status(204).end();
  }

  if (origin && !allowedOrigins.has(origin)) {
    return res.status(403).json({ status: 'origin not allowed' });
  }

  if (req.method === 'POST') {
    console.log('newRes', req.body);
    return res.status(200).json(req.body);
  }

  res.setHeader('Allow', 'POST, OPTIONS');
  return res.status(405).json({ status: 'invalid method' });
}

需要留意两点:

  1. OPTIONS 必须结束响应,不能只设置 Header 后继续等待。
  2. 实际的 POST 响应也必须包含 CORS Header。即使预检成功,后续响应仍然需要 Access-Control-Allow-Origin。

如果项目使用了全局鉴权、CSRF 检查或其他中间件,应在这些检查之前判断 OPTIONS。预检请求通常不带业务凭证,如果先执行鉴权,很容易返回 401 或 403。

调整 vercel.json

静态配置可以保留。建议使用完整、明确的 JSON,并避免同时使用通配符和凭证:

{
  "headers": [
    {
      "source": "/api/(.*)",
      "headers": [
        {
          "key": "Access-Control-Allow-Origin",
          "value": "http://127.0.0.1:5500"
        },
        {
          "key": "Access-Control-Allow-Methods",
          "value": "GET,OPTIONS,PATCH,DELETE,POST,PUT"
        },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "Content-Type,Authorization,X-CSRF-Token,X-Requested-With,Accept"
        }
      ]
    }
  ]
}

请求确实需要 Cookie 或 HTTP 认证时,再增加:

{
  "key": "Access-Control-Allow-Credentials",
  "value": "true"
}

前端也要明确启用凭证:

fetch(apiUrl, {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(postData),
});

启用凭证后,不能使用以下组合:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *

Access-Control-Allow-Origin 此时必须填写具体来源,例如:

Access-Control-Allow-Origin: http://127.0.0.1:5500

如果不需要 Cookie,可以直接删除 Access-Control-Allow-Credentials。原始 fetch 示例没有设置 credentials: 'include',通常不需要允许凭证。

也不建议在 Access-Control-Allow-Headers 中把 * 与其他名称混写。当前请求只需允许 Content-Type;使用 Authorization 时,再把它明确加入。

检查 Vercel 本地环境是否加载了配置

即使函数已经正确处理 OPTIONS,也要确认本地服务是否从预期目录运行。可以进入包含 vercel.json 的项目根目录后启动:

vercel dev --listen 4002

启动后逐项确认:

  • vercel.json 是合法、完整的 JSON。
  • /api/v1/dummy 与 dummy.ts 的实际目录映射一致。
  • 请求没有被 rewrites、redirects 或框架路由改写。
  • 终端日志中能看到 OPTIONS 请求。
  • 本机使用的 Vercel CLI 和项目依赖均已完整安装。
  • 没有代理、防病毒软件或其他程序占用 4002 端口。

电脑型号不会改变浏览器的 CORS 规则。如果问题是在换电脑后出现的,更可能是本地 CLI、启动目录、环境变量、依赖、路由构建结果或中间件执行路径发生了变化。

Nuxt 3 中也可以使用同源代理

如果 Nuxt 页面只访问自己的后端,可以创建 Nuxt server route。浏览器请求同源地址,再由服务端转发到 Vercel API。服务端请求不受浏览器 CORS 限制。

例如创建:

// server/api/dummy.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);

  return await $fetch('http://localhost:4002/api/v1/dummy', {
    method: 'POST',
    body,
  });
});

页面改为请求 Nuxt 自己的地址:

const result = await $fetch('/api/dummy', {
  method: 'POST',
  body: {
    userId: 1,
    id: 101,
    title: 'Custom Post Title',
    body: 'Custom Post Body',
  },
});

这种方式能避免浏览器发起跨源请求。不过,Vercel API 的 OPTIONS 问题仍然应该修复,因为以后可能还有其他浏览器客户端直接调用该接口。

验证修复结果

修复后按以下顺序检查:

  1. 单独请求 OPTIONS,确认状态为 204 或其他 2xx。
  2. 确认响应中包含与当前页面来源匹配的 Access-Control-Allow-Origin。
  3. 确认 Access-Control-Allow-Methods 包含 POST。
  4. 确认 Access-Control-Allow-Headers 包含 Content-Type。
  5. 从 HTML 页面或 Nuxt 客户端发送 POST。
  6. 如果预检成功但 POST 仍被拦截,检查 POST 响应本身是否也包含 CORS Header。

备注:内容仅供参考。