前后端运行架构与 API 边界
MyIP 的运行入口由两个 Express 应用组成:前端服务器负责构建产物、浏览器路由回退与 /api 代理,后端服务器负责公共请求管线、业务路由及本地数据初始化。本页从服务器入口解释两者如何协作,以及哪些行为属于全局边界、哪些必须由具体业务处理器决定。
目的与范围
本文覆盖运行脚本、生产请求分流、API 中间件顺序、路由注册、缓存策略声明、启动就绪和运行风险。IP 信息、DNS、ASN、报告和用户功能只说明入口及保护策略;各供应商协议、结果结构、数据库实现、用户认证和部署编排应在相应专题中展开。
**证据边界:**本页依据两个服务器入口和包配置编写。没有核验浏览器组件请求代码、Vite 代理配置、公共守卫及业务处理器内部实现,因此不把路由注册推断为完整 API 参数协议,也不把服务器注释视为底层实现的独立验证。运行上下文未提供其他目录页路径,故不生成猜测性的 Wiki 链接。
概述
- 构建阶段:
build执行vite build;前端运行服务器读取dist,而不是现场编译 Vue。 - 开发阶段:
dev并行运行 Vite 与通过 nodemon 启动的后端;它不启动生产前端服务器。 - 常规运行阶段:
start并行启动前端和后端 Node 进程。默认前端端口为18966,后端为11966。 - **浏览器访问边界:**前端先把
/api交给后端代理,再尝试静态文件,最后处理符合条件的 SPA 导航。 - **后端边界:**统一的请求日志、限流、JSON 解析、默认缓存头和 Referer 守卫位于业务处理器之前;部分路由额外使用参数守卫、本地数据就绪门禁和缓存声明。
依据:package.json、frontend-server.js、backend-server.js。
运行架构
Sources: frontend-server.js、backend-server.js。
这里的前端服务器是静态资源与代理入口,并非服务端 Vue 渲染器。后端也挂载了 dist 静态服务,但入口代码没有相同的 setStaticHeaders 与 SPA 回退逻辑。因此,直接访问后端端口不等价于访问前端端口:即使都能读取某些构建产物,深层页面导航与缓存行为仍不同。
图中的路由级中间件是按需组合的,不表示每条 API 都经过 requirePublicIP 或 requireOfflineData。
1. 进程、构建与运行环境
package.json 声明 ES Module、pnpm@12.4.2 和 Node 版本范围 ^24.15.0 || >=26.0.0。重要脚本如下:
| 脚本 | 实际职责 | 边界说明 |
|---|---|---|
dev | 并行启动 Vite 和 nodemon 后端 | 开发代理地址未在已读源码中核验 |
build | vite build | 不启动服务器 |
postbuild | 存在时运行缓存清除脚本 | 转发子进程退出状态;没有该文件则不运行 |
preview | vite preview | 不能据此认为它具有生产前端服务器的代理配置 |
start-backend | 预加载 Sentry instrumentation 后启动后端 | 只启动后端 |
start-frontend | 启动前端 Express | 只启动静态资源与代理入口 |
start | 并行启动两个 Node 入口 | 脚本本身没有先等待后端就绪 |
test / check | Node 测试;测试通过后构建 | 测试断言和覆盖范围未读取 |
源码示例:独立或联合启动
"start-backend": "node --import ./sentry-instrument.js backend-server.js",
"start-frontend": "node frontend-server.js",
"start": "concurrently \"node frontend-server.js\" \"node --import ./sentry-instrument.js backend-server.js\"",Source: package.json。
这是 scripts 对象的原始片段,不是独立 JSON 文件。后端脚本显式使用 --import;仅用普通 Node 执行后端文件与仓库脚本的预加载行为并不完全一致。
2. 前端入口:代理、静态资源与 SPA 边界
2.1 /api 优先代理
1frontendApp.use('/api', createProxyMiddleware({
2 target: `http://localhost:${backEndPort}/api`,
3 changeOrigin: true
4}));Source: frontend-server.js。
代理目标使用 localhost 和共享的 BACKEND_PORT,并设置 changeOrigin: true。入口没有声明 pathRewrite、代理重试、超时或自定义失败响应;不能为这些行为补充未经验证的项目级保证。生产拓扑变更为远程后端时,单独改端口不足以更换目标主机。
2.2 静态文件缓存按类别分配
setStaticHeaders(res, filePath) 先把路径转换为相对于 dist 的 / 分隔路径,再按下表顺序匹配:
| 资源类别 | 响应 Cache-Control 策略 |
|---|---|
assets/、fonts/ | public,浏览器缓存 365 天,immutable |
favicons/ | public,缓存 30 天 |
| 任意层级的 png、jpg、jpeg、webp、svg、ico | public,缓存 7 天 |
.html、manifest.webmanifest、根级 SEO 文件 | 浏览器 max-age=0,共享缓存 s-maxage=86400,must-revalidate |
| 其他资源 | public,缓存 1 小时 |
根级 SEO 集合为 llms.txt、llms-full.txt、sitemap.xml、robots.txt。优先级很重要:assets/ 下的图片先命中一年策略,而不是普通图片的七天策略。源码注释明确把哈希资源、稳定字体和非哈希图片分开;非哈希图片更新需要配合文件名变更,避免旧内容长时间保留。
2.3 SPA 回退不是任意路径兜底
1frontendApp.use((req, res, next) => {
2 if (req.method !== 'GET' || !req.accepts('html')) return next();
3 if (req.path.split('/').pop().includes('.')) return next();
4 res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
5 res.sendFile(path.join(distDir, 'index.html'));
6});Source: frontend-server.js。
只有 GET、接受 HTML 且路径末段不含点号的请求才进入回退。判断并不是严格的“存在扩展名”,而是末段任意位置包含 . 就排除。因此带点的客户端路由也不会回退。
这个限制让缺失的 JS/CSS 资源继续落入未处理请求路径,而不是被错误地返回 HTML。Accept: */* 也可能通过 HTML 接受判断,所以末段检查承担独立的资源保护作用。静态层返回的 /、/index.html 与回退得到的深层页面使用不同缓存策略,后者明确不缓存。
3. API 的全局请求管线
后端注册顺序决定哪些错误能够携带哪些头、进入哪些日志。真实顺序如下:
app.set('trust proxy', 1)。- 可选
/apiHTTP 日志。 - 可选
/api请求次数限制。 - 可选
/api渐进延迟。 - 全局
express.json({ limit: '500kb' })。 /api默认Cache-Control: no-store。/api全局requireReferer。- 路由级守卫、缓存声明与业务处理器。
- 后端静态文件服务。
- 条件注册的 Sentry Express 错误处理器。
依据:backend-server.js、backend-server.js、backend-server.js。
**不能把后置中间件的保证外推到所有响应:**限流器直接返回的 429,以及 JSON 解析阶段拒绝的请求,均发生在默认 no-store 和 Referer 守卫之前。正常进入该缓存头中间件的 API 响应才首先获得该默认值。
3.1 流量保护与客户端 IP
| 机制 | 时间窗口与行为 | 豁免 |
|---|---|---|
rateLimiter | 20 分钟;达到配置上限后返回 429 JSON | /monitoring |
speedLimiter | 1 小时;超出阈值后按超出次数乘 400ms 延迟,最多 5000ms | /monitoring、/maxmind |
monitoringLimiter | 20 分钟 600 次,仅挂在遥测 POST 路由 | 无额外豁免声明 |
普通限制器配置为 0 时不挂载。MaxMind 只豁免渐进延迟,并没有豁免普通请求次数限制。
getClientIp(req) 按 cf-connecting-ip、x-forwarded-for 首项、cf-connecting-ipv6、req.ip 的顺序获取限流告警记录使用的地址。限制器未显式把这个函数设置为配额键生成器,不能声称该顺序就是计数身份算法。代理信任层数固定为 1,也应与实际入口拓扑一起审核。
3.2 缓存与安全守卫是不同边界
所有已注册 API 都位于 requireReferer 之后,包括用户、报告和遥测路由。守卫的具体允许规则和失败状态码未读取,因此不能把它描述为登录认证或授权系统。
默认不缓存,再由各路由显式调用 cacheable(...),使缓存成为逐路由声明而非全局默认。本文表格中的 TTL 是注册传参和源码注释所声明的策略,不是对 cacheable 内部所有状态码、响应头及条件判断的完整说明。
4. API 注册参考:方法、保护和缓存归属
下表覆盖服务器入口注册的路由。所有路径均以 /api 为前缀;“默认”表示没有路由级 cacheable 声明,正常经过公共缓存中间件后保持 no-store。参数名、响应对象和处理器异常只有在入口明确体现时才列出,不推测完整业务协议。
| 方法与相对路径 | 路由级处理或守卫 | 缓存声明 |
|---|---|---|
GET /service-status | needsServiceStatus → serviceStatusHandler | 5 分钟 |
GET /service-status/detail | requireValidProviderId()、needsServiceStatus → serviceStatusDetailHandler | 5 分钟 |
GET /ipinfo、/ipapicom、/ipsb、/ipapiis、/ip2location | 各自处理器前使用 requirePublicIP()、withTimeZone() | 1 天 |
GET /maxmind | requirePublicIP()、needsMaxMind、withTimeZone() → maxmindHandler | 1 天 |
GET /whois | normalizeAsnQuery() → getWhois | 1 天 |
GET /github-stars | githubStarsHandler | 1 天 |
GET /configs | validateConfigs;注释说明提供环境变量派生的功能标志 | 1 小时 |
GET /ooni-blocking | requireValidDomain() → ooniBlockingHandler | 1 天 |
GET /globalping-probes | globalpingProbesHandler | 7 天 |
GET /asn-profile | requireValidASN() → asnProfileHandler | 完整结果 7 天;降级策略见下文 |
GET /cfradar | cfRadarHandler;req.query.view 选择注册项 TTL | 按视图决定,完整性条件控制 |
GET /asn-history | requireValidPrefix() → asnHistoryHandler | 30 天 |
GET /asn-connectivity | requireValidASN()、needsAsGraph → asnConnectivityHandler | 30 天 |
GET /macchecker | needsOui → macChecker | 30 天 |
GET /map | mapHandler | 1 年 |
GET /ipchecking | requirePublicIP()、withTimeZone() → ipCheckingHandler | 默认 |
GET /dnsresolver | requireValidDomain('hostname')、requireValidRecordType() → dnsResolver | 默认 |
GET /dnsleaktest/session/:token | dnsLeakGetResult | 默认 |
GET /invisibility | invisibilitytestHandler | 默认 |
GET /getuserinfo | getUserinfo | 默认 |
PUT /updateuserachievement | updateUserAchievement | 默认 |
GET /report/:id | requireValidReportId() → getReportHandler | 默认 |
POST /report | createReportHandler | 默认 |
POST /persona/evaluate | personaEvaluateHandler | 默认 |
POST /monitoring | 条件注册;独立限流、raw 解析 → sentryTunnelHandler | 默认 |
依据:backend-server.js。缺少显式路由级守卫不等于处理器内部没有校验;例如表格不能证明用户端点允许匿名写入。
4.1 完整结果、降级结果与隐私结果
ASN Profile 并不使用整条路由的离线数据门禁。入口通过完整性谓词和降级 TTL,把部分结果与完整结果分开处理:
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。
该声明表达三层策略:完整结果使用七天 TTL;离线初始化期间降级 TTL 为零;初始化结束后降级 TTL 为一天。源码注释解释,这样既避免把启动时数据缺失长期缓存,也减少对持续失败上游的重复访问。具体谓词如何判断字段完整性,需查看相应专题。
Cloudflare Radar 使用 RADAR_VIEWS[req.query.view]?.ttl 与 isCompleteRadarAnswer。入口注释给出 ASN 汇总及前缀列表七天、国家流量三十天、故障信息一小时,并明确部分结果不缓存;非法 view 的实际错误协议不能从这里推出。
报告读取和创建始终没有公共缓存声明。代码注释的理由是:边缘缓存可能超过报告 KV 的有效期,且私人诊断结果不适合公开缓存。这证明的是此处的缓存设计意图,不是对报告存储后端实现的核验。
4.2 JSON 与遥测 envelope 的不同入口
普通请求统一经过 express.json({ limit: '500kb' })。源码说明共享报告可能达到约 100KB,所以把解析器上限提高到 500KB;报告处理器还应执行其自身的更紧限制,但该限制值未在本页读取。
遥测路由仅在 VITE_SENTRY_DSN_FRONTEND 为真值时注册,额外安装 express.raw({ type: () => true, limit: '10mb' })。函数形式的 type 用于匹配没有 Content-Type 的二进制 Replay envelope;普通 '*/*' 字符串匹配不满足注释所述需求。这个 raw 解析器仍位于全局 JSON 解析器之后,因此不能概括为“所有遥测内容都绕过 JSON 层、统一允许 10MB”。
依据:backend-server.js、backend-server.js。
5. 核心请求流程
下面以已经转发到后端、没有被公共前置保护拒绝的 MaxMind 请求说明路由执行顺序:
Sources: frontend-server.js、backend-server.js。
该流程中的 503 是入口注释描述的启动门禁行为;具体响应体与门禁错误处理未读取。withTimeZone() 的注册位置已确认,但其字段计算和响应包装方式不在本页推断。
6. 启动生命周期与就绪边界
bootBackend() 是无参数 async 函数,末尾直接调用。它并不等待所有离线下载完成后再开放监听,而是尽量加载已有快照,然后让需要缺失数据的路由承担局部不可用。
Source: backend-server.js。
图中的两个初始化任务表示传入 runOfflineBootstrap 的任务集合,不承诺该函数内部的并行调度方式。
关键时序与后果:
- 获取 MaxMind City 与 ASN 文件路径;两者均存在才先尝试加载,失败由空
catch吞掉。 - 调用
app.listen,回调记录后端 ready 日志。监听成功不是全量数据就绪。 - 在下载之前调用
watchDatasets。注释解释,这样另一个进程发布或人工放入的数据不会被误当成 watcher 的初始基线而漏掉重载。 - 等待
runOfflineBootstrap:提交数据集初始化和服务状态初始化任务;数据集任务结束后再次检查 MaxMind,必要时重试加载并记录错误。 - 初始化返回后,才启动数据集调度器与服务状态轮询。
入口组装了四类门禁:MaxMind、AS 关系与组织、OUI、服务状态。它们分别检查 isMaxMindReady、isAsRelLoaded 与 isAsOrgLoaded、isOuiLoaded、isServiceStatusPrimed。注释描述下载失败为非致命,并指出不同功能后续可能持续 503 或降级;具体降级数据和状态应以业务专题为准。
源码示例:监听之后执行后台初始化
1 watchDatasets(datasets);
2
3 await runOfflineBootstrap([
4 async () => {
5 await bootstrapDatasets(datasets);
6 if (isMaxMindReady()) return;
7 await reloadMaxMindDatabases('startup').catch(() => {
8 logger.error('❌ MaxMind API will return 503 until databases are available: set MAXMIND_ACCOUNT_ID + '
9 + 'MAXMIND_LICENSE_KEY, or drop GeoLite2-City.mmdb + GeoLite2-ASN.mmdb into common/maxmind-db/');
10 });
11 },
12 bootstrapServiceStatus,
13 ]);
14
15 startDatasetScheduler(datasets);
16 startServiceStatusPolling();Source: backend-server.js。
7. 配置选项
两个入口都调用 dotenv.config({ quiet: true }),端口和流量阈值通过十进制 parseInt 读取。入口没有额外的数值范围验证,生产配置应避免空白、非法数字和不合理阈值。
| 配置名 | 入口读取类型 | 默认值或启用条件 | 影响范围 |
|---|---|---|---|
FRONTEND_PORT | 字符串转整数 | 18966 | 前端监听端口 |
BACKEND_PORT | 字符串转整数 | 11966 | 后端监听端口和前端代理目标端口 |
SECURITY_RATE_LIMIT | 字符串转整数 | 0,不挂载 | 普通 API 的 20 分钟请求上限 |
SECURITY_DELAY_AFTER | 字符串转整数 | 0,不挂载 | 普通 API 的一小时延迟阈值 |
SECURITY_BLACKLIST_LOG_FILE_PATH | 字符串 | 空串,不写文件 | 限流告警的可选本地账本 |
LOG_HTTP | 字符串精确比较 | 仅 'true' 启用 | /api 请求日志 |
VITE_SENTRY_DSN_FRONTEND | 字符串真值判断 | 未设置则不注册 | 后端遥测转发路由 |
SENTRY_DSN_BACKEND | 字符串真值判断 | 未设置则不安装此错误处理器 | Sentry Express 错误处理 |
MAXMIND_ACCOUNT_ID、MAXMIND_LICENSE_KEY | 此入口未解析 | 无法从入口确认默认值 | MaxMind 不可用时日志提示的恢复配置 |
依据:frontend-server.js、backend-server.js、backend-server.js、backend-server.js。
此外,JSON 上限、监控 raw 上限、各缓存时长、监控配额和代理信任层数都是当前入口中的常量,不是本页可确认的环境变量选项。
8. 故障、并发与运维注意事项
8.1 排障时先区分故障所属层
| 现象 | 优先检查的已验证边界 |
|---|---|
| 页面可访问,API 不可用 | 前端与后端是独立进程;检查后端端口与启动日志 |
| 深层页面刷新失败 | 是否绕过了前端入口;方法、Accept 和路径末段是否符合回退条件 |
| 发布后缺失资源 | 缺失的带点文件不会被回退 HTML 掩盖;检查构建产物和资源 URL |
| 429 | 普通限流与 /monitoring 专用限流是两套配置 |
| 仅部分离线数据 API 返回 503 | 监听已开放不代表数据已下载;检查对应门禁和初始化日志 |
| 请求在处理器前被拒绝 | 先检查限流、JSON 大小与 Referer 边界,不只查看业务代码 |
| 用户结果或报告缓存异常 | 这些路由未声明公共缓存;同时检查外部缓存层是否覆盖源站策略 |
代理失败的具体状态码、Referer 失败响应、统一业务错误体和未知 API 路径格式没有在已读入口中显式定义;不得当作固定协议对外承诺。
8.2 日志与本地账本不等价于防火墙
限流处理器仅在 req.rateLimit.current === req.rateLimit.limit + 1 时记录一次警告,避免对每个被阻止请求重复刷日志。配置了账本路径才调用 logLimitedIP(ip);该函数更新命中次数,保留地址首次出现时间,并使用带 UTC 偏移的本地时间字符串。
该账本按“异步读取整个文件 → 修改文本 → 写回整个文件”执行,入口没有锁或请求串行队列。并发写入可能覆盖彼此更新,这是由读改写结构推导的风险,不是已执行的压力测试结论。它也没有被读取作为封禁规则,因此不要把文件名中的 blacklist 误解成额外的持久化拦截机制。
8.3 性能、扩容与可观测性
- HTTP 日志默认关闭;开启后 5xx 或错误记为
error、4xx 为warn、其他为info。它位于限流之前,因此可以观察 429。 - 两套普通限流器与监控限流器均未显式配置外部共享 store。入口不足以证明多实例配额全局一致,扩容时需单独核验。
- 缓存时长针对数据变化速度区分;公开、慢变的数据与个人结果隔离,避免用长期公共缓存覆盖每访客数据。
- 前后端联合启动没有 readiness 握手;早期前端代理请求可能碰到后端尚未监听,入口没有项目自定义重试机制。
- 已读入口未显示信号处理或显式停止 watcher、调度器、轮询器的关闭流程,不能保证优雅退出顺序。
依据:backend-server.js、backend-server.js、backend-server.js、package.json。
9. 扩展与验证建议
新增 API 时,应沿用入口中已存在的组合方式,而不是在每个处理器重复基础边界:
- 在公共 Referer 中间件之后、静态服务之前注册业务路由。
- 明确选择 HTTP 方法、参数守卫以及是否依赖本地数据;不要为无关路由增加全局启动阻塞。
- 默认保持不缓存;只有明确属于可共享结果时才声明 TTL,并为部分失败考虑完整性条件。
- 涉及特殊二进制载荷时审核解析器顺序,而不只是提高路由的大小限制。
- 若添加不同资源类别,注意
setStaticHeaders的首个匹配分支决定最终静态缓存策略。
这些是基于现有结构的维护建议,并非仓库已经实现的自动检查。可确认的测试入口是 Node 内置测试运行器与 check 脚本;没有读取测试文件,不能宣称代理、缓存、限流或启动门禁已获测试覆盖。
相关链接
- 运行命令与验证入口:了解开发、构建、独立启动和测试脚本。
- 前端导航与缓存边界:定位资源缓存和刷新页面问题。
- API 路由注册与策略声明:作为各业务 API 专题的入口。
- 离线初始化与后台任务启动:区分监听成功与功能数据就绪。