
remix fetch-proxy 完全指南基于 Fetch API 的 HTTP 代理、请求转发与 Cookie 重写实战【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixremix-run/fetch-proxy是 Remix 生态中一个基于 Web 标准 Fetch API 中的版本演进反推代理库的设计要点。一、它是什么用fetch而不是 TCP 套接字实现代理传统代理通常工作在网络层需要解析原始 HTTP 报文。而fetch-proxy的定位完全不同它构建在fetch之上把转发请求抽象成调用一次 fetch。从源码入口看整个包只导出一个核心工厂函数packages/fetch-proxy/src/index.ts导出createFetchProxy以及FetchProxyOptions、FetchProxy两个类型packages/fetch-proxy/src/lib/fetch-proxy.ts核心实现约 150 行无任何运行时依赖仅依赖remix-run/headers中的SetCookie解析类见 packages/headers/src/lib/set-cookie.ts。官方 README 给了一个最小可运行示例创建一个指向https://remix.run的代理然后把任意Request丢给它返回的就是目标站点的Responseimport { createFetchProxy } from remix/fetch-proxy // 创建指向 remix.run 的代理proxy 本身就是一个 fetch 函数 let proxy createFetchProxy(https://remix.run) // 这个 fetch handler 通常运行在你的服务器代码里 function handleFetch(request: Request): PromiseResponse { return proxy(request) } // 手动构造一个 Request 测试它 let response await handleFetch(new Request(https://shopify.com)) let text await response.text() let title text.match(/title([^])\/title/)[1] assert(title.includes(Remix))注意示例里把https://shopify.com的请求转发了出去返回的却是remix.run的页面——这正是代理的本质对外接口是标准 fetch对内实现是请求改写 转发。在 Remix 文档的进阶章节中createFetchProxy()被列为用 Fetch 代理 HTTP 请求的标准方案见 docs/guides/app/actions/docs/chapters/16-advanced-guides.md同时文档特别强调要校验目标地址不要把用户输入直接变成开放代理。二、安装与导入方式包以 workspace 形式存在于本仓库的 packages/fetch-proxy 目录通过remix聚合包对外暴露npm i remiximport { createFetchProxy } from remix/fetch-proxy在 packages/remix/src/fetch-proxy.ts 中可以看到它只是一行再导出export * from remix-run/fetch-proxy因此从聚合入口与从包入口导入完全等价。包的exports字段同时暴露.与./package.json见 packages/fetch-proxy/package.json。运行时环境从 v0.6.0 起该包为 ESM-only。如果你的项目是 CommonJS需要用动态import()引入const { createFetchProxy } await import(remix-run/fetch-proxy)依赖方面v0.7.1 之后remix-run/headers从 peerDependencies 调整为普通 dependenciesv0.7.0 时曾改为 peerDependencies随后又改回这意味着安装本包时会自动带上它依赖的 headers 解析器无需手动安装。三、URL 拼接规则目标路径与请求路径如何合并createFetchProxy(target, options)接收一个目标地址字符串或URL在内部做如下处理见 fetch-proxy.ts目标路径以/结尾时先去掉末尾斜杠原请求的 querysearch拼到目标 URL 上原请求 pathname 若不为/则拼接到目标 pathname 之后目标路径为空或/时直接使用请求路径。测试用例精确锁定了这四种组合见 packages/fetch-proxy/src/lib/fetch-proxy.test.ts请求 URL目标 URL代理后的 URLhttp://shopify.comhttps://remix.run:3000/desthttps://remix.run:3000/desthttp://shopify.com/?qremixhttps://remix.run:3000/desthttps://remix.run:3000/dest?qremixhttp://shopify.com/search?qremixhttps://remix.run:3000/https://remix.run:3000/search?qremixhttp://shopify.com/search?qremixhttps://remix.run:3000/desthttps://remix.run:3000/dest/search?qremix这个目标路径作为前缀的语义也是后面 Cookie Path 重写的依据上游 cookie 的路径/{targetPath}/...会被还原成/{requestPath}/...。四、Options 详解四个配置项各自影响什么FetchProxyOptions只有四个可选字段全部有默认值见 fetch-proxy.ts配置项类型默认值作用fetchtypeof globalThis.fetchglobalThis.fetch替换底层实际执行 fetch 的实现便于测试、日志或自定义传输层rewriteCookieDomainbooleantrue是否把上游Set-Cookie的Domain属性重写为请求方的域名rewriteCookiePathbooleantrue是否把上游 cookie 的Path属性中与目标路径匹配的前缀移除xForwardedHeadersbooleanfalse是否在转发的请求中注入X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Port默认行为是只重写 cookie、不注入转发头——测试does not append X-Forwarded headers by default断言了三个X-Forwarded-*头均为null见 fetch-proxy.test.ts。4.1 自定义 fetch可测试性的关键options.fetch让代理变成纯函数式组件。测试文件中的testProxyhelper 正是用它拦截并捕获实际发出的请求从而在不启动真实服务器的情况下断言代理行为见 fetch-proxy.test.ts。这也意味着你可以把代理接到 undici、Bun 的 fetch、或任何实现了 Fetch 接口的自定义传输上。五、请求转发细节Host、Accept-Encoding 与 RequestInit 属性5.1 丢弃入站 Host避免头悬挂从 v0.8.4 开始对应 CHANGELOG 中 the incomingHostheader is dropped instead of being forwardedissue #10769代理会无条件删除入站请求的Host头proxyHeaders.delete(Host) proxyHeaders.delete(Accept-Encoding)原因是Host必须由目标 URL 决定。如果保留客户端发来的Host目标服务器可能据此路由到错误的虚拟主机。测试does not forward the incoming Host header验证了这一点见 fetch-proxy.test.ts。5.2 不转发 Accept-Encoding压缩状态交由 fetch 决定从 v0.8.5 开始入站Accept-Encoding不再转发见 CHANGELOG。原因在 README 里写得很清楚Accept-Encoding描述的是最终客户端的能力而 fetch 会自行决定如何与上游协商编码。转发它可能导致上游按客户端的偏好压缩而 fetch 层解码后正文与头部不一致。5.3 完整转发 RequestInit 属性从 v0.5.0 起代理会把已有 Request 对象上的全部属性原样搬进转发请求v0.4.0 只转发 optionsv0.5.0 修复了方法名转发回归并补全属性列表见 CHANGELOGlet proxyInit: RequestInit { method: request.method, cache: request.cache, credentials: request.credentials, integrity: request.integrity, keepalive: request.keepalive, mode: request.mode, redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, signal: request.signal, ...init, // 显式传入的 init 拥有更高优先级 headers: proxyHeaders, }要点...init展开在最后因此proxy(request, { method: POST })这类二次调用可以覆盖原请求属性——测试allows init to override request properties验证了这一点见 fetch-proxy.test.ts非 GET/HEAD 请求会带上request.body并按 WHATWG Fetch 规范强制设置duplex: half源码中有针对 TS 类型的 cast 注释见 fetch-proxy.tsheaders单独交给proxyHeaders处理不会被...init覆盖这是 v0.8.1 修复的重点当init.headers存在时生成的X-Forwarded-*头必须保留测试keeps X-Forwarded headers when proxy(url, init) provides custom headers见 fetch-proxy.test.ts。5.4 两种调用风格代理返回值本身就是一个fetch函数见FetchProxy类型fetch-proxy.ts因此支持两种写法// 风格一传入 Request 对象 let response await proxy(new Request(https://shopify.com/api/resource, { method: PUT, cache: no-store, credentials: omit, })) // 风格二proxy(url, init)与原生 fetch 完全一致 let response await proxy(https://shopify.com/api/resource, { method: PATCH, headers: { X-Custom: value }, })测试文件专门用describe(fetch proxy (double-arg style))一组用例覆盖了风格二包括自定义头转发、body 转发、默认值GET等场景见 fetch-proxy.test.ts。六、X-Forwarded-* 头何时注入、端口如何计算设置xForwardedHeaders: true后代理会向转发请求追加三个头见 fetch-proxy.tsproxyHeaders.append(X-Forwarded-Proto, url.protocol.replace(/:$/, )) proxyHeaders.append(X-Forwarded-Host, url.host) proxyHeaders.append(X-Forwarded-Port, getForwardedPort(url))X-Forwarded-Port是 v0.8.0 新增的能力见 CHANGELOG其计算逻辑值得注意请求 URL 显式带端口时原样透传否则按协议回填默认端口——https:返回443其余返回80见getForwardedPortfetch-proxy.ts。测试覆盖了带端口与不带端口两种情形见 fetch-proxy.test.ts// http://shopify.com:8080/search?qremix → // X-Forwarded-Proto: http // X-Forwarded-Host: shopify.com:8080 // X-Forwarded-Port: 8080 // http://shopify.com/search?qremix → X-Forwarded-Port: 80 // https://shopify.com/search?qremix → X-Forwarded-Port: 443这些头通常供目标服务器区分真实客户端协议与代理与上游之间的协议例如生成绝对链接、判断 HTTPS 时使用。注意默认关闭只有明确开启才会注入。七、响应头清理Content-Encoding、Content-Length 与 Transfer-Encoding由于代理基于 fetch 而非原始 HTTP 报文fetch 层可能已经解压了上游响应体也可能不暴露传输层分帧细节因此响应头中描述编码与分帧的元数据必须清理否则会出现头部说 gzip、正文却是明文的错配。v0.8.5 的规则可以精确概括为对应 fetch-proxy.tslet hasContentEncoding responseHeaders.has(Content-Encoding) let hasTransferEncoding responseHeaders.has(Transfer-Encoding) let hasProxiedResponseBody request.method ! HEAD response.body ! null responseHeaders.delete(Transfer-Encoding) if (hasTransferEncoding || (hasProxiedResponseBody hasContentEncoding)) { responseHeaders.delete(Content-Length) } if (hasProxiedResponseBody hasContentEncoding) { responseHeaders.delete(Content-Encoding) }关键判定条件场景处理响应带 body 且声明了Content-Encoding删除Content-Encoding和Content-Length响应带Transfer-Encoding删除Transfer-Encoding同时删除Content-Length两者互斥HEAD 响应无 body保留Content-Encoding与Content-Length元数据供客户端预判 GET 的结果304 等无 body 响应同上保留元数据未编码的普通响应Content-Length原样保留测试给出了完整证据链strips gzip response headers when fetch returns a decoded body用compressResponse构造真实 gzip 响应先证明带过期Content-Encoding/Content-Length头的解码响应体在真实 HTTP 客户端下会解析失败再断言代理剥离这些头后一切正常见 fetch-proxy.test.tspreserves content encoding metadata for HEAD responses与...for 304 responsesHEAD 与 304 不删元数据见 fetch-proxy.test.tsstrips ambiguous content encoding headers for unsupported content encodings即便上游编码值无法识别如foobar只要带 body 就一律剥离避免歧义见 fetch-proxy.test.ts。7.1 如果确实要压缩交给 compressResponse清理编码头意味着代理层不负责最终客户端的压缩。官方 README 的推荐做法是代理返回后用remix/response包中的compressResponsehelper 按最终客户端的Accept-Encoding重新压缩import { createFetchProxy } from remix/fetch-proxy import { compressResponse } from remix/response/compress let proxy createFetchProxy(https://remix.run) async function handleFetch(request: Request): PromiseResponse { let response await proxy(request) return compressResponse(response, request) }这种代理转发时不压、回程时再压的分层设计与fetch语义天然契合compressResponse依赖在 packages/response 包中测试中的压缩响应正是由它构造的见 fetch-proxy.test.ts。八、Set-Cookie 重写Domain 与 Path 的完整规则代理跨越了两个域名边界上游下发的 cookie 若不做改写浏览器不会把 cookie 存到客户端域名下。fetch-proxy用remix-run/headers的SetCookie类见 packages/headers/src/lib/set-cookie.ts解析每个Set-Cookie头再逐条改写见 fetch-proxy.ts。8.1 Domain 重写rewriteCookieDomain上游 cookie 的Domain被替换为请求 URL 的 hostname但有三类特殊情况getCookieDomain见 fetch-proxy.ts永远不带端口Domain属性不允许包含端口v0.8.1 修复了此前把remix.run:3000这种带端口域名写进 cookie 的问题localhost / IP 请求生成 host-only cookielocalhost、IPv4如127.0.0.1、IPv6如[2001:db8::1]请求一律省略Domain属性输出 host-only cookiepunycode 域名非 ASCII 域名会被规范化为 punycode如xn--mnich-kva.example再写入。测试矩阵完整覆盖了这些分支见 fetch-proxy.test.ts请求上游 cookie改写结果http://localhost:5173/...Domainremix.run无 Domainhost-onlyhttps://preview.example.com:8443/...Domainremix.runDomainpreview.example.comhttp://127.0.0.1:5173/...Domainremix.run:3000无 Domainhost-onlyhttp://[2001:db8::1]:5173/...Domainremix.run无 Domainhost-onlyhttps://xn--mnich-kva.example:8443/...Domainremix.run:3000Domainxn--mnich-kva.example此外上游本身不带Domain的 host-only cookie 会被原样保留测试preserves host-only upstream cookies while rewriting paths见 fetch-proxy.test.ts。8.2 Path 重写rewriteCookiePathPath 重写与目标路径对应上游 cookie 的Path若以targetUrl.pathname /开头则截掉目标路径前缀若恰好等于目标路径则改写为/if (header.path.startsWith(targetUrl.pathname /)) { header.path header.path.slice(targetUrl.pathname.length) } else if (header.path targetUrl.pathname) { header.path / }例如目标为https://remix.run:3000/dest请求为http://shopify.com/search?qremixPath/dest/search→Path/searchPath/dest→Path/对应测试rewrites cookie domain and path见 fetch-proxy.test.ts。8.3 关闭重写let proxy createFetchProxy(https://remix.run, { rewriteCookieDomain: false, // 保留上游 Domain rewriteCookiePath: false, // 保留上游 Path })测试确认关闭后 cookie 头与上游完全一致见 fetch-proxy.test.ts。典型场景是目标与客户端本就同域或上游 cookie 需要原样透传。九、从 CHANGELOG 看版本演进proxy 库的设计要点CHANGELOG 完整记录了 v0.1.02024-09-12到 v0.8.5 的演进可以把关键变更映射到设计决策版本变更对应设计点v0.1.0初始发布—v0.2.0增加 CommonJS 构建兼容 CJS 项目v0.3.0/src进入 npm 包、go to definition直达源码统一类型改用 esbuild开发者体验当前files字段仍包含src见 package.jsonv0.4.0转发全部附加 optionsRequestInit 透传v0.5.0改名mjackson/fetch-proxy→remix-run/fetch-proxy修复 method 转发回归补全 cache/credentials/integrity/keepalive/mode/redirect/referrer/referrerPolicy/signal属性全量转发v0.6.0BREAKING移除 CJSESM-only现代运行时方向CJS 需动态 importv0.7.0remix-run/headers改为 peerDependenciesesbuild →tsc构建依赖治理dist 目录镜像 src 布局v0.7.1peerDependencies 改回普通 dependencies降低安装摩擦v0.8.0新增X-Forwarded-Port转发头补全v0.8.1修复Set-CookieDomain 端口问题与 localhost/IP host-only cookie保留自定义init.headers下的X-Forwarded-*Cookie 边界语义 头合并优先级v0.8.2 / v0.8.3升级remix-run/headers依赖跟随上游v0.8.4丢弃入站Host头#10769避免虚拟主机错配v0.8.5不转发Accept-Encoding剥离带 body 响应的Content-Encoding/Content-Length剥离Transfer-Encoding与相关Content-Length编码/分帧头一致性从这条时间线可以提炼出一个通用结论基于 fetch 的代理难点从来不是转发而是边界——请求方的 Host 与 Accept-Encoding 属于请求边界、cookie 的 Domain/Path 属于应用边界、Content-Encoding 与 Transfer-Encoding 属于传输边界。每个版本的 patch 都在收紧这三条边界的语义。十、实战在 Node/Bun 服务器中接入代理结合 node-fetch-serverREADME 中列出的关联包用于在 Node.js 上用 Web Fetch API 构建 HTTP 服务器一个完整的代理服务形态如下import { createFetchProxy } from remix/fetch-proxy // node-fetch-server 提供基于 fetch 的服务器抽象 // import { createServer } from remix/node-fetch-server let proxy createFetchProxy(https://api.upstream.example, { xForwardedHeaders: true, // 让上游知道真实协议/主机/端口 }) // 服务器拿到请求后直接交给代理 async function handleFetch(request: Request): PromiseResponse { return proxy(request) } // handleFetch 即可作为 node-fetch-server 的请求处理器注册配置决策速查需要上游感知真实客户端生成绝对 URL、判断 HTTPS→ 开xForwardedHeaders: true需要给最终客户端压缩响应 → 代理返回后再调compressResponse见 7.1 节代理层本身不压目标与客户端同域 → 可关掉rewriteCookieDomain/rewriteCookiePath保留上游原始 cookie自定义传输层undici 实例、日志包装→ 注入options.fetch公开暴露的代理 → 务必校验目标地址防止被当作开放代理滥用见 16-advanced-guides.md。十一、测试与验证如何自己跑一遍包内测试通过remix-run/test运行测试文件覆盖 URL 拼接、方法/头/体转发、双参数调用风格、cookie 重写全矩阵、编码头清理、X-Forwarded-*注入共 20 用例# 在仓库根目录执行 pnpm --filter remix-run/fetch-proxy test测试亮点是readResponseWithUndicihelper见 fetch-proxy.test.ts它把代理产出的Response通过真实node:http服务器 undici 客户端再走一遍 HTTP 协议用于验证头部声称的编码与真实 body 是否一致——这比纯对象断言更贴近线上行为。测试同时兼容 Buntest:bun脚本并针对 Bun 在 Request 默认值上的差异做了条件断言。十二、总结fetch-proxy用约 150 行代码证明了标准 fetch 也能做生产级代理。它的价值不在于转发本身而在于把代理必须处理的边界问题Host 归属、编码一致性、cookie 域映射、转发头注入收敛为四个可配置选项和一组可测试的默认行为。阅读 源码 与 测试 是理解这些边界的最佳路径——CHANGELOG 中每一条 patch 记录都能在测试用例里找到对应的回归断言。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考