Nuxt:routeRules.proxy、server/middleware 与 server/api 的请求链路

已修订更新于

首发于

本文记录一次真实项目(Nuxt 4 管理后台 + HttpOnly Cookie 会话 + 同源 /api/admin/**)中的鉴权代理问题排查结论,并给出可复用的最佳实现:浏览器只带 Cookie,Nitro 在服务端转成 Authorization 再转发上游。

目标场景与错误设想

典型约束:

  1. 前端异步请求同源 /api/**,用 credentials: 'include'HttpOnly Cookie
  2. 上游 BFF / 业务 API 只认 HTTP Header(如 Authorization: Bearer …)。
  3. Token 不能进 JS,必须由 Nuxt 服务端完成 Cookie → Header

最容易想到的「改动最小」方案:

浏览器 + Cookie
  → server/middleware 把 Cookie 写进 Authorization
  → nuxt.config routeRules.proxy 透明转发到上游

这条链路不可靠,不要用。 routeRules.proxy 是 Nitro 偏底层的透明代理,匹配后常直接转发,不会先跑完你的 server/middleware 再带着改过的头出去。

对照实验:X-Forwarded-For(2026-07-16)

cookie-header-bff(Nuxt 4.4)代理到 drizzle-vitelocalhost:4010)复现:

  1. server/middleware 写入 X-Forwarded-For: nuxt-bff/<ip>
  2. routeRules/users proxy 到 http://127.0.0.1:4010/users
  3. 上游 Hono 中间件打印该头

上游日志实际收到的是:

[x-forwarded-for] ::1
[x-forwarded-for] 127.0.0.1
[x-forwarded-for] direct-test/1.2.3.4   # 仅当 curl 直打 4010 并手写 Header
[x-forwarded-for] (missing)

从未出现 nuxt-bff/… 说明请求要么根本没进 Nuxt server/middleware,要么 middleware 改的头未被 routeRules.proxy 带出。结论与 Cookie→Authorization 场景相同:需要改头时,必须用 server/api / server/routes handler 显式转发。

可靠做法是:去掉该路径的 routeRules.proxy,用 server/api / server/routes handler 显式读 Cookie(或写 XFF)、再转发。 前端几乎不用改,仍请求同源路径。

flowchart LR
  A[浏览器 Cookie] --> B[server/api catch-all]
  B --> C[Cookie → Authorization]
  C --> D[上游 BFF / API]

下文先对比三种机制,再给可直接落地的最佳实现骨架;中间穿插 oa-web 的排查过程。

三种机制各自干什么

机制 配置位置 谁在执行 典型用途
routeRules.proxy nuxt.config.ts Nitro 内置反向代理(偏底层) 零代码把某路径原样转到外部 URL
server/middleware/* server/middleware/ Nitro 服务端中间件 改请求头、日志、鉴权注入(前提:请求先进入 Nitro 常规管线
server/api/* server/api/ Nitro 文件路由(BFF / 自定义代理) 登录 BFF、带逻辑的 API 转发、改响应体

另外还有 middleware/*.global.tsapp/middleware:这是 Vue 路由中间件,只管页面导航(例如 /console/login),不参与 /api/admin/** 的 HTTP 代理。

调试前的设计:routeRules.proxy

// nuxt.config.ts(问题版本,已移除)
routeRules: {
  '/api/admin/**': {
    proxy: `${NUXT_API_PROXY_TARGET}/api/admin/**`
  }
}

浏览器请求过程(简化):

sequenceDiagram
  participant Browser
  participant Nuxt as Nuxt_Nitro
  participant Proxy as routeRules_proxy
  participant Backend as 真实后端

  Browser->>Nuxt: GET /api/admin/menus/user + Cookie
  Note over Nuxt: 期望 server/middleware 注入 Authorization
  Nuxt->>Proxy: 命中 routeRules.proxy
  Proxy->>Backend: 透明转发(常不带 Authorization)
  Backend-->>Browser: 401 未登录或 Token 已过期

日志里证实的问题

结论:routeRules.proxy 是透明转发,不会走 server/api/admin/[...path].ts,也不能指望 server/middleware 在代理前稳定地把 Cookie 转成 Authorization

登录类请求:一直用 server/api

sequenceDiagram
  participant Browser
  participant BFF as server_api_auth
  participant ServerApi as createServerApi
  participant Backend as 真实后端

  Browser->>BFF: POST /api/auth/login
  BFF->>ServerApi: 调 dingtalk/login 等
  ServerApi->>Backend: 由 BFF 处理鉴权
  Backend-->>BFF: access_token + user
  BFF->>Browser: Set-Cookie oa_auth + 只返回 user

调试后的业务 API:server/api/admin/[...path].ts

去掉 routeRules/api/admin/** 代理后,由 catch-all 路由 接管:

sequenceDiagram
  participant Browser
  participant Handler as server_api_admin_path
  participant Ky as createAdminProxyClient
  participant Backend as 真实后端

  Browser->>Handler: GET /api/admin/menus/user + Cookie
  Handler->>Handler: getAuthHeaderFromEvent(event)
  Note over Handler: Cookie 解析 + Bearer 规范化
  Handler->>Ky: ky.get('menus/user')
  Ky->>Backend: Authorization: Bearer xxx
  Backend-->>Handler: 200 + JSON
  Handler-->>Browser: 原样透传 body/status

关键实现点:

server/middleware/proxy-auth.ts 可保留作补充(给仍走 event.node.req/api/admin 请求写 authorization),但 可靠路径是 handler + createAdminProxyClient

最佳实现:Cookie → Header 的同源 BFF

把上述结论收成一套可复用分层。原则:会话改造只发生在 server/apirouteRules 只管页面渲染策略,不再代理业务 API。

分层

路径 / 文件 职责
登录 BFF server/api/auth/* 调上游换 token,写 HttpOnly Cookie,响应里只回用户信息
业务代理 server/api/admin/[...path].ts 读 Cookie → Authorization,透传 method / query / body / status
会话工具 shared/auth-session.ts Cookie 读写、Bearer 规范化(避免双重 encodeURIComponent
前端 useClientApi 只打同源 /api/...credentials: 'include'
配置 nuxt.config.ts 不要/api/admin/**routeRules.proxy

请求顺序(业务 API):

server/middleware(可选:日志等,不依赖它做鉴权转换)
  → server/api/admin/[...path].ts
      → 从 event 读 Cookie
      → 设置 Authorization
      → fetch / ky 打到 NUXT_API_PROXY_TARGET
  → 原样回传 status + body 给浏览器

注意:server/apiserver/routes 是 Nitro 路由表里的两类 handler,同一请求只会命中一个;业务代理放在 server/api 即可(自动带 /api 前缀)。app/middleware 只管页面导航,不参与 Cookie→Header。

1. 配置:去掉业务路径的 proxy

// nuxt.config.ts — 不要这样写业务代理
// routeRules: {
//   '/api/admin/**': { proxy: `${process.env.NUXT_API_PROXY_TARGET}/api/admin/**` }
// }

export default defineNuxtConfig({
  runtimeConfig: {
    apiProxyTarget: '', // NUXT_API_PROXY_TARGET
    public: {
      apiBaseUrl: '/api/admin/', // NUXT_PUBLIC_API_BASE_URL
    },
  },
  // routeRules 仍可用于页面:prerender、ssr: false 等
})

2. 业务 catch-all:在 handler 里转换并转发

// server/api/admin/[...path].ts
export default defineEventHandler(async (event) => {
  const path = (event.context.params?.path as string | string[] | undefined)
  const targetPath = Array.isArray(path) ? path.join('/') : (path ?? '')

  const auth = getAuthHeaderFromEvent(event) // 从 Cookie 解析并规范为 Bearer …
  const config = useRuntimeConfig(event)
  const base = String(config.apiProxyTarget).replace(/\/$/, '')

  const method = getMethod(event)
  const query = getQuery(event)
  const url = `${base}/api/admin/${targetPath}`

  const headers: Record<string, string> = {}
  if (auth) headers.authorization = auth

  const contentType = getHeader(event, 'content-type')
  if (contentType) headers['content-type'] = contentType

  const hasBody = !['GET', 'HEAD'].includes(method)
  const body = hasBody ? await readRawBody(event) : undefined

  const upstream = await $fetch.raw(url, {
    method,
    query,
    headers,
    body,
    // 业务错误码也要透传,不要在这里 throw
    ignoreResponseError: true,
  })

  setResponseStatus(event, upstream.status)
  const ct = upstream.headers.get('content-type')
  if (ct) setResponseHeader(event, 'content-type', ct)

  return upstream._data
})

要点:

3. 前端:同源 + Cookie,零感知 Header

// 浏览器侧:只带 Cookie,从不碰 Authorization
await $fetch('/api/admin/menus/user', { credentials: 'include' })

CSR 拉取菜单等数据时注意:相对 URL 的 ky/$fetch 在 SSR 无 origin 时可能报错,管理台菜单类请求更适合放在 onMounted / 客户端。

4. 明确不要做的事

做法 结果
server/middleware + routeRules.proxy Cookie→Header 不可靠
同时开 routeRules.proxyserver/api/admin/[...path] 匹配抢跑、难排查
app/middleware 里写 Header 给 API 只管页面导航,管不到 /api/** HTTP

页面侧:app/middleware + CSR

flowchart LR
  A[访问 /console/*] --> B[middleware/console-auth.global.ts]
  B -->|CSR| C[me 校验会话]
  B -->|SSR| D[只检查 oa_auth Cookie 是否存在]
  C --> E[console layout onMounted]
  E --> F[getCurrentUserMenus via useClientApi]
  F --> G[/api/admin/menus/user]
  G --> H[server/api/admin handler]

注意:

对比:该用哪种方式

需求 routeRules.proxy server/middleware server/api
零代码转发 适合 单独不够 可选
Cookie → Authorization 本次不可靠 仅当请求走常规 Nitro 管线 推荐
改登录响应 / 写 Cookie server/api/auth
统一透传 body/status [...path].ts
useClientApi 同源 /api/admin URL 可匹配 依赖是否进管线 明确命中

不要两套代理同时开

若同时保留:

routeRules: { '/api/admin/**': { proxy: '...' } }

和:

server/api/admin/[...path].ts

可能出现谁先匹配不确定、行为难排查。业务 API 应 只保留 handler 方案

若坚持只用 routeRules.proxy

需要另找能在代理前注入鉴权的方式;按本次调试,仅靠 server/middleware + routeRules.proxy 不可靠。请直接采用上文的 最佳实现server/api/auth/* + server/api/admin/[...path].tsNUXT_API_PROXY_TARGET 只给 handler / proxy client 用,不要写进业务路径的 routeRules.proxy

一句话结论

  1. 场景:浏览器 Cookie 会话 + 上游 Header 鉴权 → 必须在 Nitro handler 里做 Cookie→Header,再转发。
  2. routeRules.proxy:适合纯静态转发;不适合会话改造;与 server/middleware 组合不可靠。
  3. server/middleware:可做日志等横切逻辑;不能假设它一定跑在 proxy 之前并改写上游请求头。
  4. server/api:登录 BFF + 业务 catch-all 代理是正确分层;前端只打同源 URL + Cookie。
  5. routeRules:继续管页面级规则(prerender、ssr: false 等),不要再和业务 /api/admin/**proxy 混用。

相关环境变量(oa-web)

变量 作用
NUXT_PUBLIC_API_BASE_URL 前端请求前缀,一般为 /api/admin/
NUXT_API_PROXY_TARGET 服务端转发真实后端根地址

浏览器只访问同源 /api/admin/**;Nitro 在 server/api handler 里把 Cookie 转为 Authorization 后转发到真实后端。