开发 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 文档的环境隔离至少有三层:
- 生产服务不注册
/docs和/openapi.json。 - 生产运行时不加载 Scalar 和 OpenAPI 文档生成器。
- 这些代码不出现在最终 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.map 的 sources,确认 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 才能发现接口;真正的访问控制仍然要落在认证、授权、限流和输入校验上。
如果业务确实需要在生产环境提供文档,更合适的做法是给文档入口增加可靠的访问控制,而不是把“隐藏路径”当作保护手段。
这次调整最终解决的并不只是两个路由。它把开发便利与生产产物的边界交给构建器明确执行:开发时保留完整文档体验,生产时在编译阶段删掉不需要的分支,再用产物扫描和真实请求确认结果。相比维护两套业务代码,这个边界更小,也更容易长期保持。