开发环境保留 API 文档,生产包彻底移除

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

开发 API 时,我希望保留一套随代码更新的文档:浏览器打开 /docs 就能调接口,/openapi.json 还能交给其他工具继续处理。但到了生产环境,这两个入口既没有开放的必要,也不该让 Scalar 和文档生成器继续留在产物里。

本文只讨论文档功能的构建边界。请求校验、RPC 类型与 OpenAPI 描述怎样共用 Schema,可以先看Hono、Zod、RPC 与 OpenAPI 如何保持同一份契约

最初看起来只需要加一个环境判断:

import { Scalar } from '@scalar/hono-api-reference';

if (process.env.NODE_ENV !== 'production') {
  app.get('/docs', Scalar({ url: '/openapi.json' }));
}

这样确实能让生产环境不注册 /docs。不过,“访问不到文档”和“生产包里没有文档代码”是两回事。静态导入已经把 Scalar 放进了模块依赖图;如果构建器无法在编译时确定 NODE_ENV,这个条件分支也只能留到运行时再判断。

先分清三个目标

API 文档的环境隔离至少有三层:

  1. 生产服务不注册 /docs/openapi.json
  2. 生产运行时不加载 Scalar 和 OpenAPI 文档生成器。
  3. 这些代码不出现在最终 Bundle 中。

只做运行时判断,通常只能稳定解决第一层。要做到第三层,构建器必须同时知道两件事:生产条件在编译时恒为真,以及被舍弃的分支包含哪些独立依赖。

Bun 的 Bundler 会做 tree shaking 和死代码消除,但前提是条件能够静态求值。也就是说,关键不在于服务器启动时有没有设置 NODE_ENV,而在于执行 bun build 时,process.env.NODE_ENV 能不能直接被替换成一个确定的 JavaScript 字符串常量。

把文档依赖放进可删除的分支

入口文件只静态导入业务应用,把文档相关依赖改成动态导入:

import app from './index';

const runtimeApp =
  process.env.NODE_ENV === 'production'
    ? app
    : await addDocumentationRoutes();

async function addDocumentationRoutes() {
  const [{ Scalar }, { createOpenApiDocument }] = await Promise.all([
    import('@scalar/hono-api-reference'),
    import('./index'),
  ]);

  const document = await createOpenApiDocument();

  return app
    .get('/openapi.json', (c) => c.json(document))
    .get('/docs', Scalar({ url: '/openapi.json' }));
}

开发环境会执行 addDocumentationRoutes(),行为与原来一致。生产构建则应该把条件提前折叠成:

const runtimeApp = app;

剩下的函数没有任何调用方,连同函数里的动态导入一起成为死代码。Scalar、文档生成函数和两个文档路由才有机会真正离开产物。

动态导入本身不是魔法。如果生产条件仍要等到程序启动后才能判断,构建器就不能删掉另一条分支,只会把文档依赖改成按需加载。它解决的是依赖边界,编译期常量解决的才是分支取舍。

用定向 define,不要顺手内联整个环境

生产构建可以明确替换 NODE_ENV

{
  "scripts": {
    "build:prod": "bun build src/main.ts --outdir ./dist --target bun --minify --no-env-inlining --define process.env.NODE_ENV='\"production\"'"
  }
}

这里有两个容易混淆的参数:

  • --define process.env.NODE_ENV='"production"' 把这个表达式替换成 JavaScript 字符串字面量,使条件可以在构建阶段求值。
  • --no-env-inlining 禁止构建器自动把其他环境变量写进产物,避免数据库地址、密钥或环境差异配置被意外固化。

二者并不冲突:前者只处理经过明确选择的常量,后者限制其余环境变量。--env-file=.env.production 也不能代替 --define。环境文件解决的是构建进程能否读取变量,不等于构建器一定会把某次属性访问视为无副作用的常量。

命令行引号在不同终端里略有差异,真正要保证的是替换值为合法的 JavaScript 字符串字面量,而不是裸的 production 标识符。

“彻底移除”也要划定边界

这套做法移除的是文档功能本身:

  • /docs/openapi.json 路由;
  • Scalar 文档界面;
  • OpenAPI 文档生成入口;
  • 只被上述功能引用的代码和依赖。

如果业务路由仍然调用 describeRoute() 做请求描述,这部分元数据未必会一起消失。它属于真实路由的运行时代码,不在已经删除的文档分支里。

想把最后一段描述字符串也清掉,往往需要自定义构建插件、维护两套路由,或者把文档定义迁到另一套注册表。这些方案不是不能做,只是会增加契约漂移的风险。对多数服务来说,更实用的边界是:生产环境没有文档入口,产物不含文档 UI 和生成器,而路由校验及少量描述元数据继续与业务代码共存。

换句话说,“彻底”应当有可以核验的对象,不能只靠文件体积变小来判断。

产物要同时做静态和运行时检查

完成构建后,我会先直接扫描生成文件:

rg -n '/docs|/openapi\.json|Scalar|generateSpecs|api-reference' dist/main.js

预期结果是没有匹配。若生成了外部 Source Map,还可以继续检查 dist/main.js.mapsources,确认 Scalar 等模块没有进入依赖清单:

rg -n '@scalar/hono-api-reference|hono-openapi' dist/main.js.map

这里要按自己的边界解释结果。如果业务路由仍使用 hono-openapi,看到它并不代表文档 UI 没有移除;这时应进一步确认 Scalar 和文档生成函数是否还在。

静态扫描之后,再启动刚刚生成的产物,检查:

  • 业务接口与健康检查正常;
  • /docs 返回 404;
  • /openapi.json 返回 404。

两类检查缺一不可。只测 404,无法证明依赖没有打包;只搜字符串,也不能证明服务注册行为符合预期。文件大小同样只能作为线索,因为压缩、Source Map、依赖版本和其他业务改动都会影响数字。

还要确认扫描的是刚生成的文件。源码已经修改、dist 却来自上一次构建,是排查这类问题时很常见的误导。产物时间、构建命令和当前提交至少要能对应起来。

这不是安全边界

移除 API 文档能减少不必要的生产代码和公开入口,但不能代替鉴权。攻击者不需要 Swagger 或 Scalar 才能发现接口;真正的访问控制仍然要落在认证、授权、限流和输入校验上。

如果业务确实需要在生产环境提供文档,更合适的做法是给文档入口增加可靠的访问控制,而不是把“隐藏路径”当作保护手段。

这次调整最终解决的并不只是两个路由。它把开发便利与生产产物的边界交给构建器明确执行:开发时保留完整文档体验,生产时在编译阶段删掉不需要的分支,再用产物扫描和真实请求确认结果。相比维护两套业务代码,这个边界更小,也更容易长期保持。

参考资料