首页
/ Dify 前端开发代理 @langgenius/dev-proxy 全解析:路由配置、热重载与 Cookie 重写机制

Dify 前端开发代理 @langgenius/dev-proxy 全解析:路由配置、热重载与 Cookie 重写机制

2026-09-06 11:47:16作者:滑思眉Philip

在 Dify 仓库中,前端(web/)开发时常常需要把 /console/api/api 等请求转发到线上或本地后端。packages/dev-proxy 目录下的 @langgenius/dev-proxy 就是为这类场景设计的通用 Hono 开发代理:它不内置任何产品专属路由、Cookie 名称或环境变量约定,所有代理路径与上游目标都在本地配置文件中显式声明。读完本文,你将掌握该包的完整安装与 CLI 用法、配置结构与路由匹配规则、CORS 策略、Cookie 重写(含 __Host-/__Secure- 前缀与多目标作用域隔离)的实现细节,并能直接参考 Dify 仓库自身的前端代理配置来搭建自己的开发链路。

一、包定位与安装

README 的描述看,这个包的核心定位是「generic Hono-based development proxy」:基于 Hono 框架(实际依赖见 package.json 中的 hono@hono/node-server),只做 HTTP/WebSocket 转发,不包含任何 Dify 专属逻辑。产品相关的配置全部外置到使用方的配置文件里。

安装与脚本接入方式:

pnpm add -D @langgenius/dev-proxy

在前端项目的 package.json 中添加脚本:

{
  "scripts": {
    "dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local"
  }
}

然后运行 pnpm dev:proxy。Dify 仓库自身就是这样使用的:web/package.json 中声明了 "dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local",并把 @langgenius/dev-proxy 作为 workspace:* 依赖引入,配置文件即 web/dev-proxy.config.ts

需要注意运行环境:package.jsonengines 字段要求 node ^24.20.0,且构建走 vite-plusvp pack / vp test)。

二、CLI 参数与热重载行为

CLI 入口为 dev-proxy,支持以下选项(与 cli.tsprintUsage 输出的帮助文本一致):

dev-proxy --config ./dev-proxy.config.ts
  • --config-c:配置文件路径,默认 dev-proxy.config.ts
  • --env-file:在求值配置文件之前先加载环境变量文件;
  • --host:覆盖配置中的 server.host
  • --port:覆盖配置中的 server.port
  • --watch:监听配置文件与环境变量文件变更并热重载(默认开启);
  • --no-watch:禁用配置与环境文件的热重载;
  • --help-h:打印帮助。

README 特别强调:不支持 --target。上游目标必须写在配置文件里,保证「路由 + 上游」的映射关系显式可查。

从源码看热重载的完整机制在 cli.tscreateDevProxyRuntime 中:

  1. 默认通过 watchDevProxyConfig(基于 c12watchConfig)监听配置文件,若指定了 --env-file,再用 chokidar 单独监听该 env 文件;
  2. 变更到达后调用 enqueueReload,任务被串行排队(reloadTask = reloadTask.then(...)),避免并发重载;
  3. 重载时先比较解析出的 host/port:若不变,仅调用 runtime.updateConfig(nextConfig) 在运行进程内替换 Hono 应用与 WebSocket 升级处理器,日志输出 reloaded config changes;若 host 或 port 变了,则关闭旧服务器并 startDevProxyServer 重新监听,日志输出 restarted on http://host:port after ...

这与 README「Behavior」一节的描述吻合:路由、CORS、target、cookie 重写变更在运行进程中生效,而监听地址变化会重建服务器。另外 CLI 注册了 SIGINT/SIGTERM 清理钩子,退出前会关闭 env 监听、config 监听和所有已 upgrade 的 WebSocket socket。

参数解析本身在 config.tsparseDevProxyCliArgs:支持 --name=value 内联写法,遇到未识别的选项会直接抛错 Unsupported dev proxy option;端口经 resolvePort 校验必须是 1–65535 的整数。默认值也在这里定义:DEFAULT_PROXY_HOST = '127.0.0.1'DEFAULT_PROXY_PORT = 5001

三、配置结构(Config Shape)

配置文件支持 .ts.mts.js.mjs 四种格式,由 c12 加载。最小完整示例(README 原文):

import { defineDevProxyConfig } from '@langgenius/dev-proxy'

export default defineDevProxyConfig({
  server: {
    host: '127.0.0.1',
    port: 5001,
  },
  routes: [
    {
      paths: '/api',
      target: 'https://example.com',
    },
  ],
  cors: {
    allowedOrigins: 'local',
  },
})

defineDevProxyConfigconfig.ts 中只是一个恒等函数,作用是提供类型推导。真正的结构定义在 types.ts

  • server:可选,host(字符串)与 port(数字);
  • routes必填DevProxyRoute[]。每项包含 paths(单个字符串或字符串数组)、target(上游 URL)、可选的 cookieRewrite(选项对象或 false 显式禁用);
  • cors:可选,allowedOrigins 取值为 'local' 或显式 Origin 数组。

加载完成后 assertDevProxyConfig 会做运行时断言:配置必须是对象且必须包含 routes 数组,否则启动即报错(Dev proxy config must include a routes array.)。

路由匹配规则:声明顺序优先 + 前缀包含

README 明确了匹配语义:routes 按声明顺序匹配,第一条命中的路由生效;每个配置的 path 同时匹配精确路径及其所有子路径,即 paths: '/api' 会命中 /api/api/apps/api/apps/123

源码印证在 server.tsfindProxyRoute

routes.find((route) =>
  normalizeRoutePaths(route.paths).some(
    (routePath) => requestPath === routePath || requestPath.startsWith(`${routePath}/`),
  ),
)

注意 startsWith(routePath + '/') 的写法:/api-v2 不会误命中 /api 路由,避免了朴素前缀匹配的经典坑。Hono 侧则通过 app.all(path)app.all(path + '/*') 两条路由注册来覆盖精确路径和子路径(registerProxyRoute),并对不以 / 开头的路径直接抛错。

因此当两组路由存在包含关系时,README 给出的实践就是「更具体的放前面」:

routes: [
  { paths: '/api/enterprise', target: 'http://127.0.0.1:5003' },
  { paths: '/api', target: 'http://127.0.0.1:5002' },
]

Dify 自身的 web/dev-proxy.config.ts 正是这一原则的现实案例:把 /console/api/enterprise/api/enterprise/admin-api/mfa/scim 等更具体的路径指向企业版上游 DEV_PROXY_ENTERPRISE_TARGET,随后才是 /console/api 与通用 /api 路由,且 /socket.io 也显式归入 console 路由以支持 WebSocket。

上游 URL 拼接

转发时的 URL 构造由 buildUpstreamUrl(target, requestPath, search) 完成:若请求路径本身以 target 的 path 为前缀(例如 target 是 https://example.com/base、请求为 /base/api),则直接使用请求路径,避免拼成 /base/base/api;否则把请求路径追加到 target path 之后。查询串原样保留。

四、CORS 策略:默认信任本地 Origin

默认情况下(allowedOrigins: 'local'),代理对来自本地开发 Origin 的带凭证 CORS 请求放行。源码中本地宿主名单是 server.ts 里的:

const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])

命中本地 Origin 时,响应会写入 Access-Control-Allow-Origin: <origin>Access-Control-Allow-Credentials: true,并追加 Vary: Origin。若要收紧到指定来源,配置成数组即可:

cors: {
  allowedOrigins: ['http://localhost:3000'],
}

此时 isAllowedDevOrigin 只做精确的 includes(origin) 判断,其余 Origin 的响应不带任何 CORS 头。

预检请求由代理自身处理,不转发给上游:OPTIONS 请求直接返回 204,并带上 Access-Control-Allow-Methods: GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONSAccess-Control-Allow-Headers 会回显客户端的 Access-Control-Request-Headers,缺省值为 Authorization, Content-Type, X-CSRF-Token。源码中还有一个针对私有网络请求的细节:若客户端带了 Access-Control-Request-Private-Network: true,代理会回 Access-Control-Allow-Private-Network: true——这正是本地前端直连 127.0.0.1 上游时浏览器「private network access」预检所需。

WebSocket Upgrade 请求同样受同一 Origin 策略约束:createWebSocketUpgradeHandler 在升级握手阶段就检查 Origin,不合法直接回 403 Forbidden,未命中路由回 404

五、两个典型场景

场景一:本地前端经单台代理访问线上后端

前端请求 http://127.0.0.1:5001/api/apps,代理转发到 https://cloud.example.com/api/apps

import { defineDevProxyConfig } from '@langgenius/dev-proxy'

const target = process.env.DEV_PROXY_TARGET || 'https://cloud.example.com'

export default defineDevProxyConfig({
  server: {
    host: process.env.DEV_PROXY_HOST || '127.0.0.1',
    port: Number(process.env.DEV_PROXY_PORT || 5001),
  },
  routes: [
    {
      paths: '/api',
      target,
    },
  ],
})

配合可选的 .env 文件:

DEV_PROXY_TARGET=https://cloud.example.com
DEV_PROXY_HOST=127.0.0.1
DEV_PROXY_PORT=5001

启动命令 dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local。这里 --env-file 的加载时机很关键:从 config.tscreateC12ConfigOptions 看,env 文件在 c12 求值配置文件之前注入(dotenv 选项开启 interpolate: true),因此 process.env.DEV_PROXY_TARGET 在配置脚本执行时已经可用。并且该 env 文件被持续监听,编辑后代理自动重载,无需手动重启。

场景二:一个前端对接两个本地后端

例如 /console/api/* 走本地 console 后端 http://127.0.0.1:5001/api/* 走本地公共 API 后端 http://127.0.0.1:5002

import { defineDevProxyConfig } from '@langgenius/dev-proxy'

const consoleApiTarget = process.env.DEV_PROXY_CONSOLE_API_TARGET || 'http://127.0.0.1:5001'
const publicApiTarget = process.env.DEV_PROXY_PUBLIC_API_TARGET || 'http://127.0.0.1:5002'

export default defineDevProxyConfig({
  server: {
    host: process.env.DEV_PROXY_HOST || '127.0.0.1',
    port: Number(process.env.DEV_PROXY_PORT || 8082),
  },
  routes: [
    {
      paths: '/console/api',
      target: consoleApiTarget,
    },
    {
      paths: '/api',
      target: publicApiTarget,
    },
  ],
})

对应的 .env

DEV_PROXY_CONSOLE_API_TARGET=http://127.0.0.1:5001
DEV_PROXY_PUBLIC_API_TARGET=http://127.0.0.1:5002
DEV_PROXY_HOST=127.0.0.1
DEV_PROXY_PORT=8082

六、Cookie 重写:让线上安全 Cookie 在本地 HTTP 下工作

这是该包最有技术含量的部分。问题背景:线上后端(如 HTTPS 站点)通常会签发带 __Host-__Secure- 前缀的安全 Cookie——浏览器要求这类 Cookie 必须是 SecurePath=/ 且不带 Domain 属性。而本地开发走 http://localhost,浏览器根本不会保存 Secure Cookie,登录态直接失效。cookieRewrite 就是为此设计的 opt-in 机制,且完全由配置驱动:包本身不认识任何应用级 Cookie 名称。

基本用法

import type { CookieRewriteOptions } from '@langgenius/dev-proxy'
import { defineDevProxyConfig } from '@langgenius/dev-proxy'

const cookieRewrite: CookieRewriteOptions = {
  hostPrefixCookies: ['access_token', 'refresh_token', /^passport-/],
}

export default defineDevProxyConfig({
  routes: [
    {
      paths: '/api',
      target: 'https://cloud.example.com',
      cookieRewrite,
    },
  ],
})
  • hostPrefixCookies:需要参与「Host 前缀转换」的 Cookie 名单,支持字符串精确匹配和正则(如 /^passport-/),匹配发生在去除安全前缀之后的名字上(shouldUseHostPrefixstripSecureCookiePrefix 再比对);
  • 设置 cookieRewrite: false 可对某条路由显式关闭重写。

双向重写流程

cookies.ts 的实现看,重写是双向的:

本地 → 上游(rewriteCookieHeaderForUpstream:浏览器发出的普通 Cookie(如 access_token=xxx)会被加回 __Host- 前缀再发给上游——当上游 target 是 HTTPS 时 useHostPrefix: true(见 server.tsuseHostPrefix: targetUrl.protocol === 'https:')。已有的 __Host- 名字保持原样,__Secure- 名字则统一改写为 __Host- 形态。这样上游按线上协议校验 Cookie 时不会拒绝。

上游 → 本地(rewriteSetCookieHeadersForLocal:上游 Set-Cookie 里的安全 Cookie 在写给本地浏览器前会被「降级」:

  • 剥离 __Host-/__Secure- 前缀(toLocalCookieName);
  • 删除 Domain=SecurePartitioned 属性,否则浏览器在 http://localhost 下会拒收;
  • SameSite=None 改写为 SameSite=LaxSameSite=None 同样要求 Secure);
  • Path= 统一归一为 Path=/

多目标作用域隔离:localCookieScope: 'target-origin'

当一个本地代理同时指向多个线上目标时(比如 Dify 前端同时代理公共云和企业版),同一个 access_token 不能跨目标串用。此时启用目标作用域:

const cookieRewrite: CookieRewriteOptions = {
  hostPrefixCookies: ['access_token', 'csrf_token', 'refresh_token'],
  localCookieScope: 'target-origin',
  csrfHeader: {
    cookieName: 'csrf_token',
    headerName: 'X-CSRF-Token',
  },
}

cookies.ts 看其机制:

  • 对每个 target 的 origin 做 FNV-1a 哈希得到作用域 key(hashScope),本地 Cookie 以 dev_proxy_<hash>_<原cookie名> 的形式存储(toScopedLocalCookieName),即不同上游的登录 Cookie 在本地浏览器中天然隔离;
  • 转发时只把「当前 target 作用域」的 Cookie 还原成上游名字发出去,其余作用域的 Cookie 以及非作用域但命中 hostPrefixCookies 的旧 Cookie 一律剔除(返回 undefined 后被 filter 掉),防止把 A 站的 token 泄漏给 B 站;
  • 若配置了 csrfHeader,代理会用当前作用域下的 csrf_token Cookie 值覆盖请求头中的 X-CSRF-Token(有值则 set,无值则 delete),解决前端残留了旧 CSRF 头导致上游校验失败的问题。

Dify 前端的 web/dev-proxy.config.ts 就是这套机制的生产级示例:hostPrefixCookies 覆盖 access_tokencsrf_tokenrefresh_tokenwebapp_access_token/^passport-/,启用 localCookieScope: 'target-origin',并声明 csrfHeader 映射 csrf_tokenX-CSRF-Token;三条路由(enterprise 组、console 组、public /api)全部挂载同一份 difyCookieRewrite

七、转发行为与底层实现细节

README「Behavior」一节列出的行为,在 server.ts 中均有对应实现,这里补充源码级的细节:

  • 路径前缀保留:匹配的路径前缀原样转发,不做重写或剥离;
  • WebSocket 支持:Upgrade 请求复用同一套路由、Origin 策略和 Cookie 重写逻辑,走 http/https 原生的 upgrade 事件(createWebSocketUpgradeHandler),成功握手后把上下游 socket 双向 pipe 起来;上游不可达时向客户端回 502 Bad Gateway。Dify 配置里显式代理 /socket.io 即依赖此能力;
  • 请求体流式转发:非 GET/HEAD 请求直接把 request.raw.body 作为 fetch 的 body 并设 duplex: 'half',大文件上传不会先在内存中缓冲;
  • Hop-by-hop 头清理:转发前删除 connectionkeep-aliveproxy-authenticateproxy-authorizationtetrailertransfer-encodingupgrade 等逐跳头,并且额外尊重 Connection: 头中列出的自定义逐跳头(createHopByHopHeaderNames);同时删除 host 头、强制 accept-encoding: identity,并要求上游若携带 origin 则改写为目标 origin;
  • 响应侧:上游响应头同样清理逐跳头,并丢弃 content-encodingcontent-length(因为上游已被要求 identity 编码,且 body 会重新流经 Hono 响应管线),set-cookie 单独提取以便重写后逐条重新 append;
  • 错误处理:Hono 全局 onError 捕获上游请求失败,统一返回 502Upstream proxy request failed. 文本,并保留 CORS 头以便本地前端能读取错误。

测试覆盖方面,包内自带 config.spec.tsserver.spec.tscookies.spec.tscli.spec.ts 四组单元测试,分别验证 CLI 参数解析、路由/URL 构造、Cookie 双向重写和服务器行为,可作为修改或二次开发时的行为基线。

八、小结

@langgenius/dev-proxy 的设计哲学是「约定极少、配置即文档」:零内置路由、零内置 Cookie 知识,把复杂度集中在三处能力上——显式的路由/上游映射、面向本地开发的带凭证 CORS、以及把线上安全 Cookie 协议适配到本地 HTTP 环境的 Cookie 重写。其热重载(host/port 不变时进程内热更新、变化时重建服务器)让 --env-file 驱动的切换上游目标变得几乎无感。在 Dify 仓库中,它是 web/ 前端对接云端的标准开发通道;如果你在自己的前端项目中遇到「本地前端 + 线上/多后端 + 安全 Cookie」的组合问题,这个包的实现(尤其是 cookies.ts 的双向重写与作用域隔离)提供了可以直接借鉴的完整参考。

登录后查看全文
热门项目推荐
相关项目推荐