Repository Wiki
jason5ng32/MyIP

响应缓存与上游服务集成

MyIP 通过路由级 Cache-Control 策略控制响应的可缓存性,并通过共享的 Fetch 封装统一上游请求超时、取消信号与 User-Agent。两者分别解决重复查询成本与外部服务调用边界问题,不应混同为服务端结果缓存。

目的与范围

本页覆盖 /api 默认缓存策略、路由 TTL、聚合响应降级规则,以及 cacheable、fetchWithTimeout、fetchUpstream 和 initUpstreamUserAgent 的实现与运维约束。

各供应商的响应字段转换、业务查询逻辑、离线数据库下载与诊断报告存储属于相邻主题,本页只解释它们如何影响缓存边界。运行时未提供相邻目录的准确链接,因此不虚构 Wiki 地址。本文依据四个核心文件的限定摘录;未检查全部处理器、部署代理配置或全部测试。

概述

这一机制由三条原则组成:

  1. 默认不缓存,路由显式选择缓存。 /api 中间件先写入 no-store;适合共享的数据查询再通过 cacheable(...) 覆盖响应头。
  2. 传输成功不代表业务结果完整。 ASN Profile 与 Cloudflare Radar 可以返回部分数据,需要额外的完整性判定,不能仅根据 HTTP 状态决定缓存。
  3. 上游访问具有明确的时间与身份边界。 普通 Fetch 默认等待 5 秒,上游预设默认等待 8 秒;后端可以注入项目标识,且不会主动覆盖调用方已有的 User-Agent。

这里的缓存中间件只修改响应头和 res.json,没有保存响应体,也没有实现缓存命中查询、Redis/KV 写入或并发请求合并。实际缓存是否发生,取决于浏览器、代理或 CDN 是否遵守这些头部;不能仅凭后端代码认定部署已经启用边缘缓存。

依据:backend-server.js、cache-control.js、fetch-with-timeout.js。

架构

Loading diagram...

Sources:

两个机制并不直接互相调用:缓存规则属于响应出口,上游封装属于请求出口。某个业务处理器是否使用 fetchUpstream、如何解析结果以及如何映射异常,需要检查该处理器;不能从路由注册推断所有服务都走统一封装。

响应缓存的实际执行路径

1. 先设置保守默认值

javascript
1app.use('/api', (req, res, next) => { 2 res.setHeader('Cache-Control', 'no-store'); 3 next(); 4});

Source: backend-server.js

该中间件位于全局 requireReferer 之前。大部分带输入校验或就绪检查的路由,也把这些检查放在 cacheable 之前,因此正常进入这段管线后,早期拒绝响应保留 no-store。注意 express.json({ limit: '500kb' }) 位于它之前:这里并不能证明 JSON 解析阶段就被拒绝的请求一定经过默认缓存中间件。

依据:backend-server.js。

2. 在进入路由时解析正常 TTL

cacheable(maxAge, { cacheIf, degradedMaxAge } = {}) 返回 Express 中间件。执行时先解析 maxAge:数字直接使用,函数以 req 为参数调用。只有结果为真值时才保存 res.locals.cacheControl 并包装 res.json;否则直接执行 next()。

这意味着动态 TTL 是在中间件执行时读取的,而不是在 JSON 发送时读取。中间件也没有检查 TTL 必须为正整数;调用方应确保配置有效,不能把真值判断当成参数校验。

3. 在发送 JSON 时决定是否覆盖头部

javascript
1 res.json = (body) => { 2 if (res.statusCode < 400) { 3 if (!cacheIf || cacheIf(body)) { 4 res.setHeader('Cache-Control', res.locals.cacheControl); 5 } else { 6 const degradedSeconds = resolve(degradedMaxAge); 7 if (degradedSeconds) res.setHeader('Cache-Control', `public, max-age=${degradedSeconds}`); 8 } 9 } 10 return originalJson(body); 11 };

Source: cache-control.js

需要特别注意以下实现细节:

  • 注释将其描述为“2xx JSON”,但实际条件是 res.statusCode < 400,也会包含通过 res.json 发送的 3xx 响应。
  • 没有 cacheIf 或其返回真值时,使用完整 TTL。
  • cacheIf(body) 否决后,才在发送时解析 degradedMaxAge;该解析函数不接收 req 或 body。
  • 降级 TTL 为假值,或状态码至少为 400 时,不写新头部。这里是“保持已有头部”,不是主动重置为 no-store,所以默认中间件和后续头部修改都很重要。
  • 判定函数是同步调用,代码不会 await。不要提供返回 Promise 的完整性检查或 TTL 解析器。
  • originalJson 通过 bind(res) 保存,包装后仍调用原始 JSON 发送方法。

4. 二进制输出需要显式配合

中间件只拦截 res.json。源码为绕过 JSON 的二进制流处理器提供 res.locals.cacheControl,让处理器在自己的成功分支手动使用。该字段始终保存完整 TTL,并不表示已经通过 cacheIf;未读取具体二进制处理器,不能保证它已正确应用此约定。

依据:cache-control.js。

路由缓存策略

TTL 都以秒表示,以下是后端路由中明确注册的策略。

TTL路由说明
300(5 分钟)/api/service-status、/api/service-status/detail带服务状态就绪检查
3600(1 小时)/api/configs注释说明环境派生功能开关通常随重新部署变化
86400(1 天)/api/ipinfo、/api/ipapicom、/api/ipsb、/api/ipapiis、/api/ip2location、/api/maxmindIP 查询;路由带公共 IP 校验及时区处理
86400(1 天)/api/whois、/api/github-stars、/api/ooni-blockingOONI 聚合窗口按 UTC 日对齐,路由注释以降低免费 API 压力解释此 TTL
604800(7 天)/api/globalping-probes探针国家覆盖变化较慢
604800 / 86400 / 不缓存/api/asn-profile完整、降级、启动期降级分别处理
按视图决定/api/cfradarTTL 来自 RADAR_VIEWS[req.query.view]?.ttl
2592000(30 天)/api/asn-history、/api/asn-connectivity、/api/macchecker历史、互联和注册类数据
31536000(365 天)/api/map长期缓存声明;流输出仍需处理器自行配合
默认 no-store/api/ipchecking、/api/dnsresolver、/api/dnsleaktest/session/:token、/api/invisibility、/api/getuserinfo、/api/updateuserachievement、/api/report/:id、/api/report、/api/persona/evaluate用户上下文、诊断或逐请求结果,没有挂载 cacheable

依据:backend-server.js。

这些差异不是简单的“所有 GET 都缓存”。例如报告明确保留 no-store,避免边缘缓存超过 KV 过期时间继续暴露报告,也避免把私有诊断数据放进公共缓存。TTL 常量是代码配置,本段没有展示环境变量覆盖机制。

聚合上游结果的完整性与降级

ASN Profile:完整缓存一周,降级通常缓存一天

javascript
1app.get('/api/asn-profile', requireValidASN(), cacheable(SEVEN_DAYS_CACHE, { 2 cacheIf: isCompleteProfile, 3 degradedMaxAge: () => (isOfflineBootstrapping() ? 0 : ONE_DAY_CACHE), 4}), asnProfileHandler);

Source: backend-server.js

这个路由没有整体就绪门禁。根据注册处注释,本地数据尚未下载完成时,相应 section 自行返回错误;不完整结果在启动下载期间不缓存,启动期结束后允许缓存一天。原因是某些上游可能持续无法查询特定 ASN,如果所有部分失败都完全禁用缓存,每次访问都会重新访问多个数据源。

isCompleteProfile 的内部字段判定不在已读取范围内。因此本页只能确认它是完整性判定入口,不能列出未经验证的状态枚举或字段规则。

Loading diagram...

Sources:

Cloudflare Radar:动态 TTL,部分结果不缓存

javascript
app.get('/api/cfradar', cacheable((req) => RADAR_VIEWS[req.query.view]?.ttl, { cacheIf: isCompleteRadarAnswer }), cfRadarHandler);

Source: backend-server.js

该路由按 view 查找 TTL,同时使用 isCompleteRadarAnswer 拒绝不完整结果进入缓存。没有提供 degradedMaxAge,因此否决时保留默认策略。

注册处注释描述:ASN 摘要及前缀列表为 7 天,国家流量为 30 天,中断信息为 1 小时。实际值应以 RADAR_VIEWS 注册项为准,本次未读取注册表。若视图没有对应 TTL,可选链产生 undefined,缓存中间件不包装响应;无效视图对应的 HTTP 错误则由处理器决定,本页不推断。

依据:backend-server.js。

上游请求生命周期

统一超时与取消信号

fetchWithTimeout(url, init = {}) 从 init 拆出 timeoutMs,默认 5000 毫秒;其余选项传给原生 fetch。每次调用独立创建 AbortController 与计时器。

javascript
1 const callerSignal = rest.signal; 2 if (callerSignal) { 3 if (callerSignal.aborted) { 4 controller.abort(); 5 } else { 6 callerSignal.addEventListener('abort', () => controller.abort(), { once: true }); 7 } 8 } 9 10 try { 11 return await fetch(url, { ...rest, signal: controller.signal }); 12 } finally { 13 clearTimeout(timer); 14 }

Source: fetch-with-timeout.js

调用方取消和定时取消都会作用于内部控制器。已经取消的 signal 会在调用 fetch 之前取消内部控制器,但函数仍然执行 fetch,由底层 Fetch 处理这一状态。finally 无论成功失败都会清理计时器,异常不在封装内吞掉。

**超时覆盖范围很重要:**计时器在 await fetch(...) 完成后就清除,而不是等待调用方完成 response.json() 或流读取。因此这个实现不是完整响应体消费的总时限。注释指出超时表现为 AbortError;代码没有引入自己的超时异常类型。

Loading diagram...

Source: fetch-with-timeout.js

后端预设:8 秒与可选 User-Agent

javascript
1export const fetchUpstream = (url, init = {}) => { 2 const headers = upstreamUserAgent && !headersCarryUA(init.headers) 3 ? { ...init.headers, 'User-Agent': upstreamUserAgent } 4 : init.headers; 5 return fetchWithTimeout(url, { timeoutMs: 8000, ...init, headers }); 6};

Source: fetch-with-timeout.js

展开顺序决定调用方的 init.timeoutMs 能覆盖 8000。一个细节是:如果显式传入 timeoutMs: undefined,它先覆盖 8000,随后在 fetchWithTimeout 解构时采用 5000 默认值。

headersCarryUA 的行为如下:

输入 headers判断方式注入行为
未提供返回 false已注册 UA 时创建带 UA 的对象
普通对象对键名执行大小写不敏感比较有 UA 就保留,否则扩展对象后添加
带 has 方法的对象,例如 Headers调用 has('user-agent')已有 UA 不覆盖
二维键值数组直接返回 true无论是否实际包含 UA,都跳过注入

数组分支是明确的保守选择,而非完整的 Fetch headers 合并器。另一个集成风险是:没有 UA 的 Headers 实例进入注入分支后,会使用对象展开而非 new Headers(...) 合并;对象展开不能作为保留 Headers 内全部请求头的可靠方式。扩展调用点时应特别验证这一组合,不能假设所有合法 HeadersInit 形式都能透明合并。

依据:fetch-with-timeout.js。

User-Agent 初始化与兼容性边界

javascript
1 const site = (process.env.VITE_SITE_URL || '').trim(); 2 const ua = ['MyIP', version && `v${version}`, site].filter(Boolean).join('/'); 3 setUpstreamUserAgent(ua); 4 return ua;

Source: upstream-ua.js

初始化函数同步读取相对于模块位置的项目包清单版本,将 MyIP、可选的 v<version> 与站点地址用 / 拼接。文件不可读或 JSON 解析失败会被捕获,仍然生成缺少版本段的 UA;站点变量缺失则省略站点段。

源码说明其目的在于避免部分上游 WAF 阻止 undici 默认的 User-Agent: node。文件系统和环境变量读取被放在后端专用模块中,而共享 Fetch 模块不访问 fs 或 process,保持浏览器可用性。初始化模块注释要求在 dotenv.config() 之后调用,以读取 .env 中的站点地址;本次未读取后端初始化调用位置,因此不据此断言启动顺序已被独立验证。

依据:upstream-ua.js。

配置与接口速查

以下签名保留源码的 JavaScript 形式,不添加不存在的 TypeScript 类型声明。

接口 / 配置参数或类型默认值返回及职责
cacheable(maxAge, { cacheIf, degradedMaxAge } = {})maxAge 为秒数或同步 (req) => seconds无有效 TTL 则不包装返回 Express 中间件
cacheIf同步 (body) => 真值或假值未提供即接受决定使用完整 TTL 还是降级策略
degradedMaxAge秒数或同步 () => seconds未提供时不覆盖缓存头仅在 cacheIf 否决时读取
fetchWithTimeout(url, init = {})Fetch 地址与选项;额外支持 timeoutMs5000 毫秒异步返回原生 Fetch 响应
fetchUpstream(url, init = {})同上通常为 8000 毫秒返回 fetchWithTimeout 的 Promise,附加可选 UA
setUpstreamUserAgent(ua)UA 值模块初值为 null设置共享 UA;假值重置为 null,无显式返回值
initUpstreamUserAgent()无参数缺失段被省略注册并返回生成的 UA 字符串
VITE_SITE_URL环境变量字符串空字符串trim() 后作为 UA 的站点段

依据:cache-control.js、fetch-with-timeout.js、upstream-ua.js。

返回值与异常约定

  • Fetch 封装不解析 JSON、不检查 response.ok,也不把 HTTP 4xx/5xx 主动转换成异常。业务处理器仍需解释状态码和上游响应体。
  • 原生 Fetch 拒绝与取消异常向调用方传播;封装只负责在 finally 中清理定时器。
  • cacheable 没有捕获 TTL 解析器、cacheIf 或 JSON 发送过程的异常,也没有验证其返回类型。
  • initUpstreamUserAgent 捕获的是读取及解析包清单的失败,不会因此中止 UA 生成。

故障、边界与并发

情况已验证行为集成注意事项
HTTP 状态至少为 400JSON 包装器不写缓存头依赖上游中间件已设置 no-store;不会纠正处理器先前设置的公共缓存头
业务失败仍以低于 400 的状态返回未提供 cacheIf 时仍可能使用完整 TTL对部分成功或软失败接口提供同步完整性判定
ASN 启动期部分失败降级 TTL 返回 0避免短暂未就绪结果长时间污染缓存
上游网络慢或调用方取消内部控制器被取消未实现自动重试、退避或供应商切换
上游返回 429 或 500封装返回 Fetch 响应,不自行重试处理器需要自己的状态处理策略
TTL 解析结果为假值不包装 res.json同时不会设置 res.locals.cacheControl
非 JSON 响应不经过包装器成功流输出需手动应用局部缓存头
多个相同查询同时到达这些公共模块没有请求去重或锁冷缓存、绕过 CDN 或多实例启动仍可能同时访问上游

每个 fetchWithTimeout 调用有独立控制器和计时器,不会因其他请求超时而自动取消。UA 则保存在模块级变量中:运行期间调用 setUpstreamUserAgent 会影响后续 fetchUpstream 调用,不是按请求隔离的配置。

调用方 signal 的监听器使用 { once: true },但函数结束时没有显式移除。若长期复用同一 signal 且从不取消,完成请求留下的监听器可能累积;once 只保证事件实际触发后移除,并不等于请求结束后移除。

以上结论限定于所读取公共模块,不能扩大为“全仓库没有重试、缓存或并发控制”。依据:cache-control.js、fetch-with-timeout.js。

性能、运维与扩展建议

运维检查顺序

  1. 先检查响应状态和实际缓存头。 未命中缓存不一定意味着 TTL 失效,也可能是路由没有缓存声明、请求被校验拒绝或完整性判定否决。
  2. 区分默认 TTL 与降级 TTL。 ASN Profile 的一天响应可能是有意保护失败上游,而非配置错误;启动期间不应出现这种降级缓存。
  3. 检查代理缓存键。 Radar 按 view 选择数据,其他查询也可能受参数影响。代理必须正确区分请求参数;仓库摘录没有证明实际 CDN 缓存键配置。
  4. 确认缓存与访问控制的关系。 public 允许共享缓存,而 Referer 检查位于应用内部;外部缓存命中时的访问控制需要部署层另外核实,不能假定请求一定回源执行门禁。
  5. 排查上游 WAF 时检查实际 UA。 包版本或站点地址缺失不会让初始化报错退出,排障时应确认最终构造值,而不是仅检查是否存在配置变量。
  6. 区分获取响应与读取响应体超时。 当前定时器不覆盖完整流消费,处理慢响应体时需要额外设计。

安全扩展点

  • 新增可共享查询时,沿用“校验 / 就绪检查 → cacheable → 处理器”的注册顺序,并按数据更新频率选择 TTL。
  • 新增聚合接口时,把业务完整性显式放进 cacheIf;只有在允许短期复用部分结果时才设置 degradedMaxAge。
  • 新增上游调用时,根据供应商延迟覆盖 timeoutMs,并在处理器中处理 HTTP 状态、响应体解析和异常映射。
  • 修改请求头合并时,应覆盖普通对象、Headers 和键值数组,不要破坏“已有 UA 不覆盖”的约定。
  • 若需要服务端结果缓存、重试、熔断、请求合并或失效清理,应独立设计;现有 HTTP 头部中间件并不提供这些功能。

这些是基于当前扩展接口提出的工程建议,不代表仓库已经实现相应增强机制。

测试证据与验证限制

定向搜索发现了缓存中间件测试,显示至少包含以下场景:正常 TTL 与 502、按请求选择 TTL、cacheIf 否决以及启动期降级策略。还发现 Radar 部分结果标记为不完整的处理器测试名称。

本次仅获得测试搜索摘录,没有完整读取或执行测试,因而不宣称测试全部通过,也不宣称上述边界已有覆盖。后续验证应重点补查 3xx JSON、Headers 注入、显式 timeoutMs: undefined、响应体读取超时及复用取消 signal 的生命周期。

相关链接