@lark-apaas/nestjs-http-forwarder
v0.1.4
Published
FullStack Nestjs server-side HTTP request forwarder (node egress, mihomo-aware)
Readme
@lark-apaas/nestjs-http-forwarder
NestJS 模块:通用内网转发能力。自动注册 controller,把请求的出口从浏览器变成 node。
⚠️ SDK 不感知代理 / 鉴权 / 隧道。实际客户内网访问的代理 / JWT / 隧道路由由部署环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)处理。
⚠️ SDK 不内置鉴权 guard。消费方在 AppModule 层面给路由加自己的 guard(如全局 NeedLoginGuard)。
自动注册的接口
ALL /api/__platform__/http-forward?targetUrl=<内网 URL>- method:取自请求自身(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)
- targetUrl:query 参数
- headers:自动透传(剔除 host / content-length / hop-by-hop)
- body:自动透传
- 响应:上游 status / headers / body 直接投射到 HTTP response
路径硬编码 /api/__platform__/http-forward,不可配置(保证所有消费方调用方式一致)。
使用
1. 注册模块
import { Module } from '@nestjs/common';
import { HttpForwarderModule } from '@lark-apaas/nestjs-http-forwarder';
@Module({
imports: [
HttpForwarderModule.forRoot({
// 全部可选
requestTimeoutMs: 30_000,
maxResponseBytes: 10 * 1024 * 1024,
proxyAuth: { commandTimeoutMs: 5_000 }, // 调优代理鉴权刷新
}),
],
})
export class AppModule {}2. 前端调用(搭配 axiosForBackend)
import { axiosForBackend } from '@lark-apaas/client-toolkit';
// GET 内网接口
const res = await axiosForBackend.get('/api/__platform__/http-forward', {
params: { targetUrl: 'http://api.corp.com/v1/users' },
});
// POST 内网接口
await axiosForBackend.post('/api/__platform__/http-forward', { qty: 3 }, {
params: { targetUrl: 'http://api.corp.com/v1/orders' },
});
// 其他 method 同理
await axiosForBackend.delete('/api/__platform__/http-forward', {
params: { targetUrl: 'http://api.corp.com/v1/orders/42' },
});前端拿到的就是真实上游响应:response.status、response.headers、response.data 跟直连内网一样。
3. node 侧调用(服务端业务代码)
后端 service 里访问内网接口,不要裸 fetch / axios(Node 内置 fetch 不读 HTTP_PROXY;代理鉴权在首次 forward() 才 lazy 初始化;也不会带审计头)。注入 HttpForwarderService 用 fetch():
import { Injectable } from '@nestjs/common';
import { HttpForwarderService } from '@lark-apaas/nestjs-http-forwarder';
@Injectable()
export class CorpApiService {
constructor(private readonly forwarder: HttpForwarderService) {}
async listOrders() {
const res = await this.forwarder.fetch('http://api.corp.com/v1/orders', {
headers: { 'x-api-key': process.env.CORP_API_KEY! }, // 内网凭证只在 node 侧
});
if (!res.ok) {
// 失败也要读完 body:不消费 body 时 undici 连接要等到 requestTimeoutMs(默认 30s)才释放
throw new Error(`corp api ${res.status}: ${await res.text()}`);
}
return res.json();
}
async createOrder(payload: { qty: number }) {
const res = await this.forwarder.fetch('http://api.corp.com/v1/orders', {
method: 'POST',
body: payload, // object → JSON.stringify + 自动补 content-type: application/json
});
return res.json();
}
}fetch() 返回 Web Response(.json() / .text() / .arrayBuffer() / headers.getSetCookie()),上游 4xx/5xx 返回 ok=false 不抛错;forwarder 自身错误(非法 URL / 上游不可达 / 超时 / 非法 header / 不支持的 body)仍抛 HttpException。body 支持 string / JSON-able object / null;Buffer / FormData / URLSearchParams / Blob / 流会被拒绝为 400。
务必消费或取消 body:不读 body 时 undici 连接会一直占用到 requestTimeoutMs(默认 30s)才被超时释放;不需要内容时 await res.body?.cancel()。
适合走 node 侧而不是前端直调的场景:内网接口需要凭证、targetUrl 不应由用户控制、定时任务 / trigger / 服务端聚合。
4. 自定义编排(高级用法)
如果默认 controller 行为不够灵活,可以直接注入 service 写自己的 controller。需要 .json() 等便利方法时用 toWebResponse() 把 ForwardResponseDto 转成 Web Response:
import { Body, Controller, Post } from '@nestjs/common';
import {
HttpForwarderService,
toWebResponse,
type ForwardRequestDto,
} from '@lark-apaas/nestjs-http-forwarder';
@Controller('my/custom')
export class MyController {
constructor(private readonly forwarder: HttpForwarderService) {}
@Post()
async forward(@Body() body: ForwardRequestDto) {
const out = await this.forwarder.forward(body); // { status, headers, body: Readable }
const res = toWebResponse(out); // → Web Response
return res.json();
}
}ForwardRequestDto / ForwardResponseDto
import type { Readable } from 'node:stream';
import type { IncomingHttpHeaders } from 'undici/types/header';
interface ForwardRequestDto {
method: string; // 由 undici 自身校验
targetUrl: string; // 必须 http:// 或 https://
headers?: Record<string, string>;
body?: string | null;
}
interface ForwardResponseDto {
status: number;
headers: IncomingHttpHeaders; // Record<string, string | string[] | undefined>
// 多值字段(set-cookie / link / via)保留数组
body: Readable; // 未消费的可读流;调用方 pipe / for-await 消费
}0.2.0 BREAKING:
headers形态从Record<string, string>改为IncomingHttpHeaders,body从string改为Readable。详情见docs/plans/2026-05-09-http-forwarder.mdPhase 3。
审计上下文 header x-miaoda-ctx
所有走转发通道的出站请求(默认 controller、forward()、fetch())都会带上 x-miaoda-ctx,供客户内网下游审计"是哪个 app / 租户 / 用户 / 是否系统账号"发起的。
编码:标准 base64(RFC 4648 §4,
+/字母表,带=padding)的 UTF-8 JSON——不是 base64url内容(四个 key 永远存在,缺省补
""/false):{ "app_id": "app_xxx", "tenant_id": "1234", "user_id": "ou_abc", "is_system_account": false }| key | 来源(
RequestContextService.getContext()) | 说明 | |---|---|---| |app_id|appId| | |tenant_id|tenantId| 统一转 string | |user_id|userId| 匿名访问为""| |is_system_account|isSystemAccount| trigger / 定时任务场景为true|不可篡改:调用方(前端或 node 侧)自带的
x-miaoda-ctx会被 SDK 无条件丢弃并重新生成,与host的处理方式一致信任模型:内容是 app 自述,SDK 不签名——沙箱内业务代码与 SDK 同进程,任何沙箱内密钥都挡不住同 app 内伪造;跨 app 伪造由代理层(per-app JWT → Edge Proxy)鉴权兜底。下游若需要权威
app_id,以代理层鉴权结果为准,不要拿此 header 做授权决策请求作用域外调用(没有
RequestContext)仍会发 header,四个字段全缺省
TypeScript 侧可引用 MIAODA_CTX_HEADER 常量与 MiaodaCtxPayload 类型。
配置参考
| 字段 | 必填 | 默认 | 说明 |
|------|------|------|------|
| requestTimeoutMs | — | 30000 | 单次请求超时(AbortController) |
| maxResponseBytes | — | 10485760 (10 MB) | 上游响应体最大字节数;超阈值返回 502 RESPONSE_TOO_LARGE |
| proxyAuth | — | undefined | v0.3.0 起,出网代理鉴权机制始终启用(按 appId 拉 jwtToken + updateProxyCmd,exec 后定时刷新)。SDK 不感知具体代理实现。本字段仅用于覆盖调优默认值,不传则全用默认。详见 docs/api/nestjs-http-forwarder.md |
service 做的 7 件核心事
| # | 能力 | 说明 |
|---|------|------|
| 1 | 协议白名单 | targetUrl 仅允许 http:// / https://(防 SSRF) |
| 2 | host / content-length 接管 | host 重写为 targetUrl.host;content-length 让 undici 自算 |
| 3 | 超时控制 | AbortController + setTimeout 默认 30s;undici 自带 Headers/Body/Connect Timeout 也兜底 |
| 4 | 响应体大小上限 | 第一层 content-length 预检超阈值直接 502;第二层 Transform 流中累计字节,超阈值 destroy 流 |
| 5 | 错误码映射 | undici 抛错按 signal.aborted / 错误类(HeadersTimeout / InvalidArgument)/ err.code 等结构化信号映射 |
| 6 | 出网代理 | EnvHttpProxyAgent 读 HTTP_PROXY / HTTPS_PROXY / NO_PROXY env 注入 dispatcher |
| 7 | 审计上下文注入 | 从 RequestContext 生成 x-miaoda-ctx(base64 JSON),调用方自带值无条件覆盖 |
method 白名单 / body 类型校验 / payload 非空校验等都没做——交给 undici 自身(undici 对配置错抛 InvalidArgumentError,由 SDK 映射为 400 INVALID_REQUEST)。
错误码
| HTTP | code | 含义 |
|------|------|------|
| 400 | INVALID_REQUEST | targetUrl 缺失/非法 URL / undici 抛 InvalidArgumentError(method/headers 配置错) |
| 400 | INVALID_TARGET_PROTOCOL | targetUrl 协议非 http/https |
| 502 | UPSTREAM_UNREACHABLE | 上游不可达 / 网络错(err.code ∈ ENOTFOUND / ECONNREFUSED / ETIMEDOUT / UND_ERR_*) |
| 502 | RESPONSE_TOO_LARGE | 上游响应体超 maxResponseBytes。预检命中时整次请求 502;流中累计兜底时 status 已发出,连接被切断(client 看到截断) |
| 504 | UPSTREAM_TIMEOUT | 单次请求超时(controller.signal.aborted 或 undici HeadersTimeoutError / BodyTimeoutError / ConnectTimeoutError) |
上游 4xx / 5xx 不抛错,原样投射到 HTTP response。
当前限制
- 请求体仅支持
string/null(fetch()额外支持 JSON-able object);binary / FormData / 流上传待后续版本 - 响应体流式 pipe 到下游 response,不做 buffer,不支持改写 / 重试
- 默认 controller 路径固定
/api/__platform__/http-forward,路径自定义请走自定义 controller proxyAuth始终启用,每个进程严格只服务一个 appId(沙箱-per-app);不同 appId 的请求会被拒绝。消费方必须提供@lark-apaas/nestjs-common(RequestContextService+PLATFORM_HTTP_CLIENT)
