@lark-apaas/nestjs-http-forwarder 0.1.2 → 0.1.3-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +33 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -98,7 +98,7 @@ function _ts_param(paramIndex, decorator) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
__name(_ts_param, "_ts_param");
|
|
101
|
-
var HttpForwarderService = class {
|
|
101
|
+
var HttpForwarderService = class _HttpForwarderService {
|
|
102
102
|
static {
|
|
103
103
|
__name(this, "HttpForwarderService");
|
|
104
104
|
}
|
|
@@ -212,8 +212,10 @@ var HttpForwarderService = class {
|
|
|
212
212
|
*
|
|
213
213
|
* 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)
|
|
214
214
|
* 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504
|
|
215
|
-
* 3. TypeError
|
|
216
|
-
*
|
|
215
|
+
* 3. TypeError + err.cause.code 是网络层错误 → UPSTREAM_UNREACHABLE 502
|
|
216
|
+
* (ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / ETIMEDOUT / ENETUNREACH / EAI_AGAIN)
|
|
217
|
+
* 4. TypeError 兜底 → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)
|
|
218
|
+
* 5. 兜底 → UPSTREAM_UNREACHABLE 502
|
|
217
219
|
*/
|
|
218
220
|
mapNetworkError(err, controller) {
|
|
219
221
|
if (err instanceof import_common.HttpException) return err;
|
|
@@ -224,6 +226,13 @@ var HttpForwarderService = class {
|
|
|
224
226
|
}, import_common.HttpStatus.GATEWAY_TIMEOUT);
|
|
225
227
|
}
|
|
226
228
|
if (err instanceof TypeError) {
|
|
229
|
+
const causeCode = _HttpForwarderService.extractCauseCode(err);
|
|
230
|
+
if (causeCode && NETWORK_ERROR_CAUSE_CODES.has(causeCode)) {
|
|
231
|
+
return new import_common.HttpException({
|
|
232
|
+
code: ERROR_CODES.UPSTREAM_UNREACHABLE,
|
|
233
|
+
message: `upstream unreachable (${causeCode})`
|
|
234
|
+
}, import_common.HttpStatus.BAD_GATEWAY);
|
|
235
|
+
}
|
|
227
236
|
return new import_common.HttpException({
|
|
228
237
|
code: ERROR_CODES.INVALID_REQUEST,
|
|
229
238
|
message: "invalid request configuration"
|
|
@@ -234,6 +243,18 @@ var HttpForwarderService = class {
|
|
|
234
243
|
message: "upstream unreachable"
|
|
235
244
|
}, import_common.HttpStatus.BAD_GATEWAY);
|
|
236
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* 从 fetch 抛出的 TypeError 中提取 cause.code(node fetch 的 undici 实现把网络错误
|
|
248
|
+
* 包到 err.cause 上)。
|
|
249
|
+
*/
|
|
250
|
+
static extractCauseCode(err) {
|
|
251
|
+
const cause = err.cause;
|
|
252
|
+
if (cause && typeof cause === "object" && "code" in cause) {
|
|
253
|
+
const code = cause.code;
|
|
254
|
+
return typeof code === "string" ? code : void 0;
|
|
255
|
+
}
|
|
256
|
+
return void 0;
|
|
257
|
+
}
|
|
237
258
|
};
|
|
238
259
|
HttpForwarderService = _ts_decorate([
|
|
239
260
|
(0, import_common.Injectable)(),
|
|
@@ -243,6 +264,15 @@ HttpForwarderService = _ts_decorate([
|
|
|
243
264
|
typeof HttpForwarderOptionsResolved === "undefined" ? Object : HttpForwarderOptionsResolved
|
|
244
265
|
])
|
|
245
266
|
], HttpForwarderService);
|
|
267
|
+
var NETWORK_ERROR_CAUSE_CODES = /* @__PURE__ */ new Set([
|
|
268
|
+
"ENOTFOUND",
|
|
269
|
+
"ECONNREFUSED",
|
|
270
|
+
"EHOSTUNREACH",
|
|
271
|
+
"ETIMEDOUT",
|
|
272
|
+
"ENETUNREACH",
|
|
273
|
+
"EAI_AGAIN",
|
|
274
|
+
"ECONNRESET"
|
|
275
|
+
]);
|
|
246
276
|
|
|
247
277
|
// src/controllers/http-forwarder.controller.ts
|
|
248
278
|
function _ts_decorate2(decorators, target, key, desc) {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/http-forwarder.module.ts","../src/const.ts","../src/controllers/http-forwarder.controller.ts","../src/services/http-forwarder.service.ts","../src/services/options.resolved.ts"],"sourcesContent":["/**\n * @lark-apaas/nestjs-http-forwarder\n *\n * NestJS 模块:通用内网转发能力。自动注册 controller `/api/sdk_innerapi/anycross/forward`\n * 接收前端请求并通过 node fetch 透传到目标 URL;同时导出 `HttpForwarderService`\n * 供需要自定义 controller 的场景使用。\n *\n * 用途:把请求的出口从浏览器变成 node。客户内网访问的代理 / 鉴权 / 隧道由部署\n * 环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)负责,SDK 不感知。\n */\n\n// 主模块\nexport { HttpForwarderModule } from './http-forwarder.module';\n\n// 控制器\nexport * from './controllers';\n\n// 服务\nexport * from './services';\n\n// 类型定义\nexport * from './types';\n\n// 常量\nexport {\n HTTP_FORWARDER_MODULE_OPTIONS,\n CONTROLLER_ROUTE,\n DEFAULT_REQUEST_TIMEOUT_MS,\n DEFAULT_MAX_RESPONSE_BYTES,\n ALLOWED_PROTOCOLS,\n HEADERS_NOT_FORWARDED_TO_UPSTREAM,\n HEADERS_NOT_RETURNED_TO_CLIENT,\n ERROR_CODES,\n} from './const';\n","import { DynamicModule, Module } from '@nestjs/common';\nimport { HTTP_FORWARDER_MODULE_OPTIONS } from './const';\nimport { HttpForwarderController } from './controllers/http-forwarder.controller';\nimport { HttpForwarderService } from './services/http-forwarder.service';\nimport { resolveOptions } from './services/options.resolved';\nimport type { HttpForwarderModuleOptions } from './types';\n\n/**\n * HttpForwarderModule\n *\n * 自动注册:\n * - `HttpForwarderController` —— 通用内网转发 endpoint `ALL /api/sdk_innerapi/anycross/forward?targetUrl=...`\n * 支持所有 HTTP method,headers / body 自动透传,上游响应直接投射到客户端。\n * - `HttpForwarderService`(可注入到自定义 controller,编排特殊场景)\n *\n * **不内置鉴权**——消费方在 AppModule 层面给路由加 guard(如全局 NeedLoginGuard)。\n *\n * 使用示例:\n * ```ts\n * @Module({\n * imports: [\n * HttpForwarderModule.forRoot({\n * requestTimeoutMs: 30_000,\n * maxResponseBytes: 10 * 1024 * 1024,\n * }),\n * ],\n * })\n * export class AppModule {}\n * ```\n *\n * 前端调用:\n * ```ts\n * axiosForBackend.get('/api/sdk_innerapi/anycross/forward', { params: { targetUrl: 'http://api.corp.com/v1/users' } });\n * axiosForBackend.post('/api/sdk_innerapi/anycross/forward', body, { params: { targetUrl: 'http://api.corp.com/v1/orders' } });\n * ```\n *\n * **注**:实际代理 / 鉴权 / 隧道由部署环境处理(如沙箱内的 mihomo 透明代理 +\n * iptables 拦截)。SDK 不感知 anycross / proxy_group_id / jwtToken。\n */\n@Module({})\nexport class HttpForwarderModule {\n static forRoot(options?: HttpForwarderModuleOptions): DynamicModule {\n const resolved = resolveOptions(options);\n return {\n module: HttpForwarderModule,\n global: true,\n controllers: [HttpForwarderController],\n providers: [\n {\n provide: HTTP_FORWARDER_MODULE_OPTIONS,\n useValue: resolved,\n },\n HttpForwarderService,\n ],\n exports: [HttpForwarderService],\n };\n }\n}\n","/** HttpForwarder module options injection token */\nexport const HTTP_FORWARDER_MODULE_OPTIONS = Symbol('HTTP_FORWARDER_MODULE_OPTIONS');\n\n/** Controller route prefix (硬编码,不可配置以保持调用方式统一) */\nexport const CONTROLLER_ROUTE = 'api/sdk_innerapi/anycross/forward';\n\n/** Default request timeout in milliseconds */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;\n\n/** Default maximum response body size in bytes (10 MB) */\nexport const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;\n\n/** Allowed target URL protocols */\nexport const ALLOWED_PROTOCOLS = ['http:', 'https:'] as const;\n\n/**\n * 入站 headers 中不能透传给上游的 hop-by-hop / transport / encoding 字段。\n * RFC 7230 §6.1 hop-by-hop + accept-encoding(由 fetch / undici 接管)。\n *\n * 注:host / content-length 由 service.buildOutgoingHeaders 在写入 fetch 入参时\n * 单点处理(host 重写为 targetUrl.host;content-length 让 fetch 自算),此处不重复。\n */\nexport const HEADERS_NOT_FORWARDED_TO_UPSTREAM = new Set([\n 'connection',\n 'keep-alive',\n 'transfer-encoding',\n 'upgrade',\n 'proxy-connection',\n 'proxy-authenticate',\n 'proxy-authorization',\n 'te',\n 'trailer',\n 'accept-encoding',\n]);\n\n/**\n * 上游响应 headers 中不应回写到下游 response 的字段。\n * - content-encoding:fetch 已解压,原 encoding 不再适用\n * - content-length:node http 会根据真实 body 重算\n * - transfer-encoding / connection / keep-alive:hop-by-hop\n */\nexport const HEADERS_NOT_RETURNED_TO_CLIENT = new Set([\n 'content-encoding',\n 'content-length',\n 'transfer-encoding',\n 'connection',\n 'keep-alive',\n 'upgrade',\n]);\n\n/** Error codes returned to callers */\nexport const ERROR_CODES = {\n INVALID_REQUEST: 'INVALID_REQUEST',\n INVALID_TARGET_PROTOCOL: 'INVALID_TARGET_PROTOCOL',\n UPSTREAM_UNREACHABLE: 'UPSTREAM_UNREACHABLE',\n UPSTREAM_TIMEOUT: 'UPSTREAM_TIMEOUT',\n RESPONSE_TOO_LARGE: 'RESPONSE_TOO_LARGE',\n} as const;\n","import {\n All,\n Controller,\n HttpException,\n HttpStatus,\n Req,\n Res,\n} from '@nestjs/common';\nimport type { Request, Response } from 'express';\nimport {\n CONTROLLER_ROUTE,\n ERROR_CODES,\n HEADERS_NOT_FORWARDED_TO_UPSTREAM,\n HEADERS_NOT_RETURNED_TO_CLIENT,\n} from '../const';\nimport { HttpForwarderService } from '../services/http-forwarder.service';\n\n/**\n * 通用内网转发 controller。\n *\n * 路径:`/api/sdk_innerapi/anycross/forward`(硬编码,所有消费方一致)。\n *\n * 接受所有 HTTP method(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)。\n * - method:直接取自入站请求自身\n * - targetUrl:取自 query `?targetUrl=...`\n * - headers:透传入站请求 headers(剔除 hop-by-hop / accept-encoding;\n * host / content-length 由 service 单点处理)\n * - body:透传入站请求 body(已经过 express body-parser,按情况序列化为字符串)\n *\n * 响应:上游 status / headers / body **直接投射到 HTTP response**,让前端 axios\n * 看到的就是真实上游响应(而非 wrap 的 JSON)。\n *\n * **SDK 不内置鉴权**——消费方在 AppModule 层面给本路由加 guard(如全局 NeedLoginGuard)。\n */\n@Controller()\nexport class HttpForwarderController {\n constructor(private readonly svc: HttpForwarderService) {}\n\n @All(CONTROLLER_ROUTE)\n async forward(@Req() req: Request, @Res() res: Response): Promise<void> {\n const targetUrl = req.query?.targetUrl;\n if (typeof targetUrl !== 'string' || !targetUrl) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_REQUEST,\n message: 'query parameter `targetUrl` is required',\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n\n const out = await this.svc.forward({\n method: req.method.toUpperCase(),\n targetUrl,\n headers: HttpForwarderController.pickIncomingHeaders(req.headers),\n body: HttpForwarderController.serializeIncomingBody(req),\n });\n\n res.status(out.status);\n for (const [key, value] of Object.entries(out.headers)) {\n if (HEADERS_NOT_RETURNED_TO_CLIENT.has(key.toLowerCase())) continue;\n res.setHeader(key, value);\n }\n res.send(out.body);\n }\n\n /**\n * 从入站请求 headers 选出可透传给上游的字段。剔除 hop-by-hop / accept-encoding。\n * 保留 cookie / authorization / x-* 等业务头。\n * host / content-length 不在此处剥离——由 service.buildOutgoingHeaders 单点处理。\n */\n static pickIncomingHeaders(incoming: Request['headers']): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, raw] of Object.entries(incoming)) {\n if (HEADERS_NOT_FORWARDED_TO_UPSTREAM.has(key.toLowerCase())) continue;\n if (raw === undefined) continue;\n out[key] = Array.isArray(raw) ? raw.join(', ') : String(raw);\n }\n return out;\n }\n\n /**\n * 把 express 已解析的 body 序列化回字符串供 service 透传。\n */\n static serializeIncomingBody(req: Request): string | null {\n const method = req.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return null;\n const body = (req as { body?: unknown }).body;\n if (body === undefined || body === null) return null;\n if (typeof body === 'string') return body;\n if (Buffer.isBuffer(body)) return body.toString('utf-8');\n if (typeof body === 'object' && Object.keys(body as object).length === 0) {\n return null;\n }\n return JSON.stringify(body);\n }\n}\n","import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';\nimport {\n ALLOWED_PROTOCOLS,\n ERROR_CODES,\n HTTP_FORWARDER_MODULE_OPTIONS,\n} from '../const';\nimport type { ForwardRequestDto, ForwardResponseDto } from '../types';\nimport type { HttpForwarderOptionsResolved } from './options.resolved';\n\n/**\n * 反向 HTTP 转发器:把入站请求\"原样\"转发到目标 URL。\n *\n * 用途:把请求的出口从浏览器变成 node。客户内网访问的代理 / 鉴权 / 隧道由\n * 部署环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)负责,SDK 不感知。\n *\n * 只做 5 件核心事:\n * 1. 协议白名单(仅 http/https,防 SSRF)\n * 2. host 重写 + content-length 剥离(语义正确性,单点处理)\n * 3. 单次请求超时(AbortController,默认 30s)\n * 4. 响应体大小上限(流式累计字节,防 OOM,默认 10MB)\n * 5. 错误码映射(给调用方稳定的 error contract)\n */\n@Injectable()\nexport class HttpForwarderService {\n constructor(\n @Inject(HTTP_FORWARDER_MODULE_OPTIONS)\n private readonly options: HttpForwarderOptionsResolved,\n ) {}\n\n async forward(payload: ForwardRequestDto): Promise<ForwardResponseDto> {\n const targetUrl = this.assertValidTargetUrl(payload?.targetUrl);\n const headers = this.buildOutgoingHeaders(payload.headers, targetUrl);\n const controller = new AbortController();\n const timeoutTimer = setTimeout(\n () => controller.abort(),\n this.options.requestTimeoutMs,\n );\n if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();\n\n try {\n const response = await fetch(targetUrl.toString(), {\n method: payload.method,\n headers,\n body: payload.body ?? undefined,\n signal: controller.signal,\n });\n const body = await this.readBoundedBody(response, controller);\n return {\n status: response.status,\n headers: Object.fromEntries(response.headers),\n body,\n };\n } catch (err) {\n throw this.mapNetworkError(err, controller);\n } finally {\n clearTimeout(timeoutTimer);\n }\n }\n\n /**\n * URL 解析 + 协议白名单。其他入参校验交给 fetch 自身。\n */\n private assertValidTargetUrl(raw: unknown): URL {\n if (typeof raw !== 'string' || !raw) {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'targetUrl is required' },\n HttpStatus.BAD_REQUEST,\n );\n }\n let parsed: URL;\n try {\n parsed = new URL(raw);\n } catch {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'Invalid targetUrl' },\n HttpStatus.BAD_REQUEST,\n );\n }\n if (\n !ALLOWED_PROTOCOLS.includes(parsed.protocol as (typeof ALLOWED_PROTOCOLS)[number])\n ) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_TARGET_PROTOCOL,\n message: `Unsupported protocol: ${parsed.protocol}`,\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n return parsed;\n }\n\n /**\n * host / content-length 的**单点处理点**:\n * - host:强制覆盖为 targetUrl.host(防上游误识别,无论调用方传什么)\n * - content-length:剥离(fetch 会按真实 body 自算)\n *\n * 其他 hop-by-hop / accept-encoding 等由 controller 已剥离,service 信任入参。\n */\n private buildOutgoingHeaders(\n incoming: Record<string, string> | undefined,\n targetUrl: URL,\n ): Record<string, string> {\n const out: Record<string, string> = {};\n if (incoming) {\n for (const [key, value] of Object.entries(incoming)) {\n const lower = key.toLowerCase();\n if (lower === 'host' || lower === 'content-length') continue;\n out[key] = value;\n }\n }\n out['host'] = targetUrl.host;\n return out;\n }\n\n /**\n * 流式读取响应体并累计字节数,超 `maxResponseBytes` 即 abort + 抛 502。\n */\n private async readBoundedBody(\n response: Response,\n controller: AbortController,\n ): Promise<string> {\n const limit = this.options.maxResponseBytes;\n if (!response.body) return '';\n const reader = response.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n total += value.byteLength;\n if (total > limit) {\n controller.abort();\n throw new HttpException(\n {\n code: ERROR_CODES.RESPONSE_TOO_LARGE,\n message: `response exceeds maxResponseBytes (${limit})`,\n },\n HttpStatus.BAD_GATEWAY,\n );\n }\n chunks.push(value);\n }\n }\n } finally {\n void reader.cancel().catch(() => undefined);\n }\n return Buffer.concat(chunks).toString('utf-8');\n }\n\n /**\n * 把 fetch 抛错 / 超时统一映射为 HttpException。\n *\n * 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)\n * 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504\n * 3. TypeError → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)\n * 4. 兜底 → UPSTREAM_UNREACHABLE 502\n */\n private mapNetworkError(err: unknown, controller: AbortController): HttpException {\n if (err instanceof HttpException) return err;\n if (controller.signal.aborted) {\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_TIMEOUT, message: 'upstream timeout' },\n HttpStatus.GATEWAY_TIMEOUT,\n );\n }\n if (err instanceof TypeError) {\n return new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'invalid request configuration' },\n HttpStatus.BAD_REQUEST,\n );\n }\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_UNREACHABLE, message: 'upstream unreachable' },\n HttpStatus.BAD_GATEWAY,\n );\n }\n}\n","import type { HttpForwarderModuleOptions } from '../types';\nimport {\n DEFAULT_MAX_RESPONSE_BYTES,\n DEFAULT_REQUEST_TIMEOUT_MS,\n} from '../const';\n\n/**\n * forRoot 入参经过默认值合并后的形态。SDK 内部统一使用此类型。\n */\nexport interface HttpForwarderOptionsResolved\n extends Required<HttpForwarderModuleOptions> {}\n\n/**\n * 校验并补默认值。配置错误(非法字段值)在模块装配时抛出,启动期 fail-fast。\n */\nexport function resolveOptions(\n options: HttpForwarderModuleOptions | undefined,\n): HttpForwarderOptionsResolved {\n const opts = options ?? {};\n const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;\n\n if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `requestTimeoutMs` must be a positive integer',\n );\n }\n if (!Number.isInteger(maxResponseBytes) || maxResponseBytes <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `maxResponseBytes` must be a positive integer',\n );\n }\n return { requestTimeoutMs, maxResponseBytes };\n}\n"],"mappings":"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","names":["import_common","HTTP_FORWARDER_MODULE_OPTIONS","Symbol","CONTROLLER_ROUTE","DEFAULT_REQUEST_TIMEOUT_MS","DEFAULT_MAX_RESPONSE_BYTES","ALLOWED_PROTOCOLS","HEADERS_NOT_FORWARDED_TO_UPSTREAM","Set","HEADERS_NOT_RETURNED_TO_CLIENT","ERROR_CODES","INVALID_REQUEST","INVALID_TARGET_PROTOCOL","UPSTREAM_UNREACHABLE","UPSTREAM_TIMEOUT","RESPONSE_TOO_LARGE","import_common","HttpForwarderService","options","forward","payload","targetUrl","assertValidTargetUrl","headers","buildOutgoingHeaders","controller","AbortController","timeoutTimer","setTimeout","abort","requestTimeoutMs","unref","response","fetch","toString","method","body","undefined","signal","readBoundedBody","status","Object","fromEntries","err","mapNetworkError","clearTimeout","raw","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","parsed","URL","ALLOWED_PROTOCOLS","includes","protocol","INVALID_TARGET_PROTOCOL","incoming","out","key","value","entries","lower","toLowerCase","host","limit","maxResponseBytes","reader","getReader","chunks","total","done","read","byteLength","RESPONSE_TOO_LARGE","BAD_GATEWAY","push","cancel","catch","Buffer","concat","aborted","UPSTREAM_TIMEOUT","GATEWAY_TIMEOUT","TypeError","UPSTREAM_UNREACHABLE","HttpForwarderController","svc","forward","req","res","targetUrl","query","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","out","method","toUpperCase","headers","pickIncomingHeaders","body","serializeIncomingBody","status","key","value","Object","entries","HEADERS_NOT_RETURNED_TO_CLIENT","has","toLowerCase","setHeader","send","incoming","raw","HEADERS_NOT_FORWARDED_TO_UPSTREAM","undefined","Array","isArray","join","String","Buffer","isBuffer","toString","keys","length","JSON","stringify","resolveOptions","options","opts","requestTimeoutMs","DEFAULT_REQUEST_TIMEOUT_MS","maxResponseBytes","DEFAULT_MAX_RESPONSE_BYTES","Number","isInteger","Error","HttpForwarderModule","forRoot","options","resolved","resolveOptions","module","global","controllers","HttpForwarderController","providers","provide","HTTP_FORWARDER_MODULE_OPTIONS","useValue","HttpForwarderService","exports"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/http-forwarder.module.ts","../src/const.ts","../src/controllers/http-forwarder.controller.ts","../src/services/http-forwarder.service.ts","../src/services/options.resolved.ts"],"sourcesContent":["/**\n * @lark-apaas/nestjs-http-forwarder\n *\n * NestJS 模块:通用内网转发能力。自动注册 controller `/api/sdk_innerapi/anycross/forward`\n * 接收前端请求并通过 node fetch 透传到目标 URL;同时导出 `HttpForwarderService`\n * 供需要自定义 controller 的场景使用。\n *\n * 用途:把请求的出口从浏览器变成 node。客户内网访问的代理 / 鉴权 / 隧道由部署\n * 环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)负责,SDK 不感知。\n */\n\n// 主模块\nexport { HttpForwarderModule } from './http-forwarder.module';\n\n// 控制器\nexport * from './controllers';\n\n// 服务\nexport * from './services';\n\n// 类型定义\nexport * from './types';\n\n// 常量\nexport {\n HTTP_FORWARDER_MODULE_OPTIONS,\n CONTROLLER_ROUTE,\n DEFAULT_REQUEST_TIMEOUT_MS,\n DEFAULT_MAX_RESPONSE_BYTES,\n ALLOWED_PROTOCOLS,\n HEADERS_NOT_FORWARDED_TO_UPSTREAM,\n HEADERS_NOT_RETURNED_TO_CLIENT,\n ERROR_CODES,\n} from './const';\n","import { DynamicModule, Module } from '@nestjs/common';\nimport { HTTP_FORWARDER_MODULE_OPTIONS } from './const';\nimport { HttpForwarderController } from './controllers/http-forwarder.controller';\nimport { HttpForwarderService } from './services/http-forwarder.service';\nimport { resolveOptions } from './services/options.resolved';\nimport type { HttpForwarderModuleOptions } from './types';\n\n/**\n * HttpForwarderModule\n *\n * 自动注册:\n * - `HttpForwarderController` —— 通用内网转发 endpoint `ALL /api/sdk_innerapi/anycross/forward?targetUrl=...`\n * 支持所有 HTTP method,headers / body 自动透传,上游响应直接投射到客户端。\n * - `HttpForwarderService`(可注入到自定义 controller,编排特殊场景)\n *\n * **不内置鉴权**——消费方在 AppModule 层面给路由加 guard(如全局 NeedLoginGuard)。\n *\n * 使用示例:\n * ```ts\n * @Module({\n * imports: [\n * HttpForwarderModule.forRoot({\n * requestTimeoutMs: 30_000,\n * maxResponseBytes: 10 * 1024 * 1024,\n * }),\n * ],\n * })\n * export class AppModule {}\n * ```\n *\n * 前端调用:\n * ```ts\n * axiosForBackend.get('/api/sdk_innerapi/anycross/forward', { params: { targetUrl: 'http://api.corp.com/v1/users' } });\n * axiosForBackend.post('/api/sdk_innerapi/anycross/forward', body, { params: { targetUrl: 'http://api.corp.com/v1/orders' } });\n * ```\n *\n * **注**:实际代理 / 鉴权 / 隧道由部署环境处理(如沙箱内的 mihomo 透明代理 +\n * iptables 拦截)。SDK 不感知 anycross / proxy_group_id / jwtToken。\n */\n@Module({})\nexport class HttpForwarderModule {\n static forRoot(options?: HttpForwarderModuleOptions): DynamicModule {\n const resolved = resolveOptions(options);\n return {\n module: HttpForwarderModule,\n global: true,\n controllers: [HttpForwarderController],\n providers: [\n {\n provide: HTTP_FORWARDER_MODULE_OPTIONS,\n useValue: resolved,\n },\n HttpForwarderService,\n ],\n exports: [HttpForwarderService],\n };\n }\n}\n","/** HttpForwarder module options injection token */\nexport const HTTP_FORWARDER_MODULE_OPTIONS = Symbol('HTTP_FORWARDER_MODULE_OPTIONS');\n\n/** Controller route prefix (硬编码,不可配置以保持调用方式统一) */\nexport const CONTROLLER_ROUTE = 'api/sdk_innerapi/anycross/forward';\n\n/** Default request timeout in milliseconds */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;\n\n/** Default maximum response body size in bytes (10 MB) */\nexport const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;\n\n/** Allowed target URL protocols */\nexport const ALLOWED_PROTOCOLS = ['http:', 'https:'] as const;\n\n/**\n * 入站 headers 中不能透传给上游的 hop-by-hop / transport / encoding 字段。\n * RFC 7230 §6.1 hop-by-hop + accept-encoding(由 fetch / undici 接管)。\n *\n * 注:host / content-length 由 service.buildOutgoingHeaders 在写入 fetch 入参时\n * 单点处理(host 重写为 targetUrl.host;content-length 让 fetch 自算),此处不重复。\n */\nexport const HEADERS_NOT_FORWARDED_TO_UPSTREAM = new Set([\n 'connection',\n 'keep-alive',\n 'transfer-encoding',\n 'upgrade',\n 'proxy-connection',\n 'proxy-authenticate',\n 'proxy-authorization',\n 'te',\n 'trailer',\n 'accept-encoding',\n]);\n\n/**\n * 上游响应 headers 中不应回写到下游 response 的字段。\n * - content-encoding:fetch 已解压,原 encoding 不再适用\n * - content-length:node http 会根据真实 body 重算\n * - transfer-encoding / connection / keep-alive:hop-by-hop\n */\nexport const HEADERS_NOT_RETURNED_TO_CLIENT = new Set([\n 'content-encoding',\n 'content-length',\n 'transfer-encoding',\n 'connection',\n 'keep-alive',\n 'upgrade',\n]);\n\n/** Error codes returned to callers */\nexport const ERROR_CODES = {\n INVALID_REQUEST: 'INVALID_REQUEST',\n INVALID_TARGET_PROTOCOL: 'INVALID_TARGET_PROTOCOL',\n UPSTREAM_UNREACHABLE: 'UPSTREAM_UNREACHABLE',\n UPSTREAM_TIMEOUT: 'UPSTREAM_TIMEOUT',\n RESPONSE_TOO_LARGE: 'RESPONSE_TOO_LARGE',\n} as const;\n","import {\n All,\n Controller,\n HttpException,\n HttpStatus,\n Req,\n Res,\n} from '@nestjs/common';\nimport type { Request, Response } from 'express';\nimport {\n CONTROLLER_ROUTE,\n ERROR_CODES,\n HEADERS_NOT_FORWARDED_TO_UPSTREAM,\n HEADERS_NOT_RETURNED_TO_CLIENT,\n} from '../const';\nimport { HttpForwarderService } from '../services/http-forwarder.service';\n\n/**\n * 通用内网转发 controller。\n *\n * 路径:`/api/sdk_innerapi/anycross/forward`(硬编码,所有消费方一致)。\n *\n * 接受所有 HTTP method(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)。\n * - method:直接取自入站请求自身\n * - targetUrl:取自 query `?targetUrl=...`\n * - headers:透传入站请求 headers(剔除 hop-by-hop / accept-encoding;\n * host / content-length 由 service 单点处理)\n * - body:透传入站请求 body(已经过 express body-parser,按情况序列化为字符串)\n *\n * 响应:上游 status / headers / body **直接投射到 HTTP response**,让前端 axios\n * 看到的就是真实上游响应(而非 wrap 的 JSON)。\n *\n * **SDK 不内置鉴权**——消费方在 AppModule 层面给本路由加 guard(如全局 NeedLoginGuard)。\n */\n@Controller()\nexport class HttpForwarderController {\n constructor(private readonly svc: HttpForwarderService) {}\n\n @All(CONTROLLER_ROUTE)\n async forward(@Req() req: Request, @Res() res: Response): Promise<void> {\n const targetUrl = req.query?.targetUrl;\n if (typeof targetUrl !== 'string' || !targetUrl) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_REQUEST,\n message: 'query parameter `targetUrl` is required',\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n\n const out = await this.svc.forward({\n method: req.method.toUpperCase(),\n targetUrl,\n headers: HttpForwarderController.pickIncomingHeaders(req.headers),\n body: HttpForwarderController.serializeIncomingBody(req),\n });\n\n res.status(out.status);\n for (const [key, value] of Object.entries(out.headers)) {\n if (HEADERS_NOT_RETURNED_TO_CLIENT.has(key.toLowerCase())) continue;\n res.setHeader(key, value);\n }\n res.send(out.body);\n }\n\n /**\n * 从入站请求 headers 选出可透传给上游的字段。剔除 hop-by-hop / accept-encoding。\n * 保留 cookie / authorization / x-* 等业务头。\n * host / content-length 不在此处剥离——由 service.buildOutgoingHeaders 单点处理。\n */\n static pickIncomingHeaders(incoming: Request['headers']): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, raw] of Object.entries(incoming)) {\n if (HEADERS_NOT_FORWARDED_TO_UPSTREAM.has(key.toLowerCase())) continue;\n if (raw === undefined) continue;\n out[key] = Array.isArray(raw) ? raw.join(', ') : String(raw);\n }\n return out;\n }\n\n /**\n * 把 express 已解析的 body 序列化回字符串供 service 透传。\n */\n static serializeIncomingBody(req: Request): string | null {\n const method = req.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return null;\n const body = (req as { body?: unknown }).body;\n if (body === undefined || body === null) return null;\n if (typeof body === 'string') return body;\n if (Buffer.isBuffer(body)) return body.toString('utf-8');\n if (typeof body === 'object' && Object.keys(body as object).length === 0) {\n return null;\n }\n return JSON.stringify(body);\n }\n}\n","import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';\nimport {\n ALLOWED_PROTOCOLS,\n ERROR_CODES,\n HTTP_FORWARDER_MODULE_OPTIONS,\n} from '../const';\nimport type { ForwardRequestDto, ForwardResponseDto } from '../types';\nimport type { HttpForwarderOptionsResolved } from './options.resolved';\n\n/**\n * 反向 HTTP 转发器:把入站请求\"原样\"转发到目标 URL。\n *\n * 用途:把请求的出口从浏览器变成 node。客户内网访问的代理 / 鉴权 / 隧道由\n * 部署环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)负责,SDK 不感知。\n *\n * 只做 5 件核心事:\n * 1. 协议白名单(仅 http/https,防 SSRF)\n * 2. host 重写 + content-length 剥离(语义正确性,单点处理)\n * 3. 单次请求超时(AbortController,默认 30s)\n * 4. 响应体大小上限(流式累计字节,防 OOM,默认 10MB)\n * 5. 错误码映射(给调用方稳定的 error contract)\n */\n@Injectable()\nexport class HttpForwarderService {\n constructor(\n @Inject(HTTP_FORWARDER_MODULE_OPTIONS)\n private readonly options: HttpForwarderOptionsResolved,\n ) {}\n\n async forward(payload: ForwardRequestDto): Promise<ForwardResponseDto> {\n const targetUrl = this.assertValidTargetUrl(payload?.targetUrl);\n const headers = this.buildOutgoingHeaders(payload.headers, targetUrl);\n const controller = new AbortController();\n const timeoutTimer = setTimeout(\n () => controller.abort(),\n this.options.requestTimeoutMs,\n );\n if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();\n\n try {\n const response = await fetch(targetUrl.toString(), {\n method: payload.method,\n headers,\n body: payload.body ?? undefined,\n signal: controller.signal,\n });\n const body = await this.readBoundedBody(response, controller);\n return {\n status: response.status,\n headers: Object.fromEntries(response.headers),\n body,\n };\n } catch (err) {\n throw this.mapNetworkError(err, controller);\n } finally {\n clearTimeout(timeoutTimer);\n }\n }\n\n /**\n * URL 解析 + 协议白名单。其他入参校验交给 fetch 自身。\n */\n private assertValidTargetUrl(raw: unknown): URL {\n if (typeof raw !== 'string' || !raw) {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'targetUrl is required' },\n HttpStatus.BAD_REQUEST,\n );\n }\n let parsed: URL;\n try {\n parsed = new URL(raw);\n } catch {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'Invalid targetUrl' },\n HttpStatus.BAD_REQUEST,\n );\n }\n if (\n !ALLOWED_PROTOCOLS.includes(parsed.protocol as (typeof ALLOWED_PROTOCOLS)[number])\n ) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_TARGET_PROTOCOL,\n message: `Unsupported protocol: ${parsed.protocol}`,\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n return parsed;\n }\n\n /**\n * host / content-length 的**单点处理点**:\n * - host:强制覆盖为 targetUrl.host(防上游误识别,无论调用方传什么)\n * - content-length:剥离(fetch 会按真实 body 自算)\n *\n * 其他 hop-by-hop / accept-encoding 等由 controller 已剥离,service 信任入参。\n */\n private buildOutgoingHeaders(\n incoming: Record<string, string> | undefined,\n targetUrl: URL,\n ): Record<string, string> {\n const out: Record<string, string> = {};\n if (incoming) {\n for (const [key, value] of Object.entries(incoming)) {\n const lower = key.toLowerCase();\n if (lower === 'host' || lower === 'content-length') continue;\n out[key] = value;\n }\n }\n out['host'] = targetUrl.host;\n return out;\n }\n\n /**\n * 流式读取响应体并累计字节数,超 `maxResponseBytes` 即 abort + 抛 502。\n */\n private async readBoundedBody(\n response: Response,\n controller: AbortController,\n ): Promise<string> {\n const limit = this.options.maxResponseBytes;\n if (!response.body) return '';\n const reader = response.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n total += value.byteLength;\n if (total > limit) {\n controller.abort();\n throw new HttpException(\n {\n code: ERROR_CODES.RESPONSE_TOO_LARGE,\n message: `response exceeds maxResponseBytes (${limit})`,\n },\n HttpStatus.BAD_GATEWAY,\n );\n }\n chunks.push(value);\n }\n }\n } finally {\n void reader.cancel().catch(() => undefined);\n }\n return Buffer.concat(chunks).toString('utf-8');\n }\n\n /**\n * 把 fetch 抛错 / 超时统一映射为 HttpException。\n *\n * 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)\n * 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504\n * 3. TypeError + err.cause.code 是网络层错误 → UPSTREAM_UNREACHABLE 502\n * (ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / ETIMEDOUT / ENETUNREACH / EAI_AGAIN)\n * 4. TypeError 兜底 → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)\n * 5. 兜底 → UPSTREAM_UNREACHABLE 502\n */\n private mapNetworkError(err: unknown, controller: AbortController): HttpException {\n if (err instanceof HttpException) return err;\n if (controller.signal.aborted) {\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_TIMEOUT, message: 'upstream timeout' },\n HttpStatus.GATEWAY_TIMEOUT,\n );\n }\n if (err instanceof TypeError) {\n const causeCode = HttpForwarderService.extractCauseCode(err);\n if (causeCode && NETWORK_ERROR_CAUSE_CODES.has(causeCode)) {\n return new HttpException(\n {\n code: ERROR_CODES.UPSTREAM_UNREACHABLE,\n message: `upstream unreachable (${causeCode})`,\n },\n HttpStatus.BAD_GATEWAY,\n );\n }\n return new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'invalid request configuration' },\n HttpStatus.BAD_REQUEST,\n );\n }\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_UNREACHABLE, message: 'upstream unreachable' },\n HttpStatus.BAD_GATEWAY,\n );\n }\n\n /**\n * 从 fetch 抛出的 TypeError 中提取 cause.code(node fetch 的 undici 实现把网络错误\n * 包到 err.cause 上)。\n */\n private static extractCauseCode(err: TypeError): string | undefined {\n const cause = (err as TypeError & { cause?: unknown }).cause;\n if (cause && typeof cause === 'object' && 'code' in cause) {\n const code = (cause as { code: unknown }).code;\n return typeof code === 'string' ? code : undefined;\n }\n return undefined;\n }\n}\n\n/**\n * fetch 抛 TypeError 时,cause.code 命中此集合视为网络层不可达,\n * 映射为 502 UPSTREAM_UNREACHABLE;其他视为请求配置错 400 INVALID_REQUEST。\n */\nconst NETWORK_ERROR_CAUSE_CODES: ReadonlySet<string> = new Set([\n 'ENOTFOUND',\n 'ECONNREFUSED',\n 'EHOSTUNREACH',\n 'ETIMEDOUT',\n 'ENETUNREACH',\n 'EAI_AGAIN',\n 'ECONNRESET',\n]);\n","import type { HttpForwarderModuleOptions } from '../types';\nimport {\n DEFAULT_MAX_RESPONSE_BYTES,\n DEFAULT_REQUEST_TIMEOUT_MS,\n} from '../const';\n\n/**\n * forRoot 入参经过默认值合并后的形态。SDK 内部统一使用此类型。\n */\nexport interface HttpForwarderOptionsResolved\n extends Required<HttpForwarderModuleOptions> {}\n\n/**\n * 校验并补默认值。配置错误(非法字段值)在模块装配时抛出,启动期 fail-fast。\n */\nexport function resolveOptions(\n options: HttpForwarderModuleOptions | undefined,\n): HttpForwarderOptionsResolved {\n const opts = options ?? {};\n const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;\n\n if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `requestTimeoutMs` must be a positive integer',\n );\n }\n if (!Number.isInteger(maxResponseBytes) || maxResponseBytes <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `maxResponseBytes` must be a positive integer',\n );\n }\n return { requestTimeoutMs, maxResponseBytes };\n}\n"],"mappings":"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","names":["import_common","HTTP_FORWARDER_MODULE_OPTIONS","Symbol","CONTROLLER_ROUTE","DEFAULT_REQUEST_TIMEOUT_MS","DEFAULT_MAX_RESPONSE_BYTES","ALLOWED_PROTOCOLS","HEADERS_NOT_FORWARDED_TO_UPSTREAM","Set","HEADERS_NOT_RETURNED_TO_CLIENT","ERROR_CODES","INVALID_REQUEST","INVALID_TARGET_PROTOCOL","UPSTREAM_UNREACHABLE","UPSTREAM_TIMEOUT","RESPONSE_TOO_LARGE","import_common","HttpForwarderService","options","forward","payload","targetUrl","assertValidTargetUrl","headers","buildOutgoingHeaders","controller","AbortController","timeoutTimer","setTimeout","abort","requestTimeoutMs","unref","response","fetch","toString","method","body","undefined","signal","readBoundedBody","status","Object","fromEntries","err","mapNetworkError","clearTimeout","raw","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","parsed","URL","ALLOWED_PROTOCOLS","includes","protocol","INVALID_TARGET_PROTOCOL","incoming","out","key","value","entries","lower","toLowerCase","host","limit","maxResponseBytes","reader","getReader","chunks","total","done","read","byteLength","RESPONSE_TOO_LARGE","BAD_GATEWAY","push","cancel","catch","Buffer","concat","aborted","UPSTREAM_TIMEOUT","GATEWAY_TIMEOUT","TypeError","causeCode","extractCauseCode","NETWORK_ERROR_CAUSE_CODES","has","UPSTREAM_UNREACHABLE","cause","Set","HttpForwarderController","svc","forward","req","res","targetUrl","query","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","out","method","toUpperCase","headers","pickIncomingHeaders","body","serializeIncomingBody","status","key","value","Object","entries","HEADERS_NOT_RETURNED_TO_CLIENT","has","toLowerCase","setHeader","send","incoming","raw","HEADERS_NOT_FORWARDED_TO_UPSTREAM","undefined","Array","isArray","join","String","Buffer","isBuffer","toString","keys","length","JSON","stringify","resolveOptions","options","opts","requestTimeoutMs","DEFAULT_REQUEST_TIMEOUT_MS","maxResponseBytes","DEFAULT_MAX_RESPONSE_BYTES","Number","isInteger","Error","HttpForwarderModule","forRoot","options","resolved","resolveOptions","module","global","controllers","HttpForwarderController","providers","provide","HTTP_FORWARDER_MODULE_OPTIONS","useValue","HttpForwarderService","exports"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -116,10 +116,17 @@ declare class HttpForwarderService {
|
|
|
116
116
|
*
|
|
117
117
|
* 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)
|
|
118
118
|
* 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504
|
|
119
|
-
* 3. TypeError
|
|
120
|
-
*
|
|
119
|
+
* 3. TypeError + err.cause.code 是网络层错误 → UPSTREAM_UNREACHABLE 502
|
|
120
|
+
* (ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / ETIMEDOUT / ENETUNREACH / EAI_AGAIN)
|
|
121
|
+
* 4. TypeError 兜底 → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)
|
|
122
|
+
* 5. 兜底 → UPSTREAM_UNREACHABLE 502
|
|
121
123
|
*/
|
|
122
124
|
private mapNetworkError;
|
|
125
|
+
/**
|
|
126
|
+
* 从 fetch 抛出的 TypeError 中提取 cause.code(node fetch 的 undici 实现把网络错误
|
|
127
|
+
* 包到 err.cause 上)。
|
|
128
|
+
*/
|
|
129
|
+
private static extractCauseCode;
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -116,10 +116,17 @@ declare class HttpForwarderService {
|
|
|
116
116
|
*
|
|
117
117
|
* 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)
|
|
118
118
|
* 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504
|
|
119
|
-
* 3. TypeError
|
|
120
|
-
*
|
|
119
|
+
* 3. TypeError + err.cause.code 是网络层错误 → UPSTREAM_UNREACHABLE 502
|
|
120
|
+
* (ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / ETIMEDOUT / ENETUNREACH / EAI_AGAIN)
|
|
121
|
+
* 4. TypeError 兜底 → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)
|
|
122
|
+
* 5. 兜底 → UPSTREAM_UNREACHABLE 502
|
|
121
123
|
*/
|
|
122
124
|
private mapNetworkError;
|
|
125
|
+
/**
|
|
126
|
+
* 从 fetch 抛出的 TypeError 中提取 cause.code(node fetch 的 undici 实现把网络错误
|
|
127
|
+
* 包到 err.cause 上)。
|
|
128
|
+
*/
|
|
129
|
+
private static extractCauseCode;
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
/**
|
package/dist/index.js
CHANGED
|
@@ -63,7 +63,7 @@ function _ts_param(paramIndex, decorator) {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
__name(_ts_param, "_ts_param");
|
|
66
|
-
var HttpForwarderService = class {
|
|
66
|
+
var HttpForwarderService = class _HttpForwarderService {
|
|
67
67
|
static {
|
|
68
68
|
__name(this, "HttpForwarderService");
|
|
69
69
|
}
|
|
@@ -177,8 +177,10 @@ var HttpForwarderService = class {
|
|
|
177
177
|
*
|
|
178
178
|
* 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)
|
|
179
179
|
* 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504
|
|
180
|
-
* 3. TypeError
|
|
181
|
-
*
|
|
180
|
+
* 3. TypeError + err.cause.code 是网络层错误 → UPSTREAM_UNREACHABLE 502
|
|
181
|
+
* (ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / ETIMEDOUT / ENETUNREACH / EAI_AGAIN)
|
|
182
|
+
* 4. TypeError 兜底 → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)
|
|
183
|
+
* 5. 兜底 → UPSTREAM_UNREACHABLE 502
|
|
182
184
|
*/
|
|
183
185
|
mapNetworkError(err, controller) {
|
|
184
186
|
if (err instanceof HttpException) return err;
|
|
@@ -189,6 +191,13 @@ var HttpForwarderService = class {
|
|
|
189
191
|
}, HttpStatus.GATEWAY_TIMEOUT);
|
|
190
192
|
}
|
|
191
193
|
if (err instanceof TypeError) {
|
|
194
|
+
const causeCode = _HttpForwarderService.extractCauseCode(err);
|
|
195
|
+
if (causeCode && NETWORK_ERROR_CAUSE_CODES.has(causeCode)) {
|
|
196
|
+
return new HttpException({
|
|
197
|
+
code: ERROR_CODES.UPSTREAM_UNREACHABLE,
|
|
198
|
+
message: `upstream unreachable (${causeCode})`
|
|
199
|
+
}, HttpStatus.BAD_GATEWAY);
|
|
200
|
+
}
|
|
192
201
|
return new HttpException({
|
|
193
202
|
code: ERROR_CODES.INVALID_REQUEST,
|
|
194
203
|
message: "invalid request configuration"
|
|
@@ -199,6 +208,18 @@ var HttpForwarderService = class {
|
|
|
199
208
|
message: "upstream unreachable"
|
|
200
209
|
}, HttpStatus.BAD_GATEWAY);
|
|
201
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* 从 fetch 抛出的 TypeError 中提取 cause.code(node fetch 的 undici 实现把网络错误
|
|
213
|
+
* 包到 err.cause 上)。
|
|
214
|
+
*/
|
|
215
|
+
static extractCauseCode(err) {
|
|
216
|
+
const cause = err.cause;
|
|
217
|
+
if (cause && typeof cause === "object" && "code" in cause) {
|
|
218
|
+
const code = cause.code;
|
|
219
|
+
return typeof code === "string" ? code : void 0;
|
|
220
|
+
}
|
|
221
|
+
return void 0;
|
|
222
|
+
}
|
|
202
223
|
};
|
|
203
224
|
HttpForwarderService = _ts_decorate([
|
|
204
225
|
Injectable(),
|
|
@@ -208,6 +229,15 @@ HttpForwarderService = _ts_decorate([
|
|
|
208
229
|
typeof HttpForwarderOptionsResolved === "undefined" ? Object : HttpForwarderOptionsResolved
|
|
209
230
|
])
|
|
210
231
|
], HttpForwarderService);
|
|
232
|
+
var NETWORK_ERROR_CAUSE_CODES = /* @__PURE__ */ new Set([
|
|
233
|
+
"ENOTFOUND",
|
|
234
|
+
"ECONNREFUSED",
|
|
235
|
+
"EHOSTUNREACH",
|
|
236
|
+
"ETIMEDOUT",
|
|
237
|
+
"ENETUNREACH",
|
|
238
|
+
"EAI_AGAIN",
|
|
239
|
+
"ECONNRESET"
|
|
240
|
+
]);
|
|
211
241
|
|
|
212
242
|
// src/controllers/http-forwarder.controller.ts
|
|
213
243
|
function _ts_decorate2(decorators, target, key, desc) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/http-forwarder.module.ts","../src/const.ts","../src/controllers/http-forwarder.controller.ts","../src/services/http-forwarder.service.ts","../src/services/options.resolved.ts"],"sourcesContent":["import { DynamicModule, Module } from '@nestjs/common';\nimport { HTTP_FORWARDER_MODULE_OPTIONS } from './const';\nimport { HttpForwarderController } from './controllers/http-forwarder.controller';\nimport { HttpForwarderService } from './services/http-forwarder.service';\nimport { resolveOptions } from './services/options.resolved';\nimport type { HttpForwarderModuleOptions } from './types';\n\n/**\n * HttpForwarderModule\n *\n * 自动注册:\n * - `HttpForwarderController` —— 通用内网转发 endpoint `ALL /api/sdk_innerapi/anycross/forward?targetUrl=...`\n * 支持所有 HTTP method,headers / body 自动透传,上游响应直接投射到客户端。\n * - `HttpForwarderService`(可注入到自定义 controller,编排特殊场景)\n *\n * **不内置鉴权**——消费方在 AppModule 层面给路由加 guard(如全局 NeedLoginGuard)。\n *\n * 使用示例:\n * ```ts\n * @Module({\n * imports: [\n * HttpForwarderModule.forRoot({\n * requestTimeoutMs: 30_000,\n * maxResponseBytes: 10 * 1024 * 1024,\n * }),\n * ],\n * })\n * export class AppModule {}\n * ```\n *\n * 前端调用:\n * ```ts\n * axiosForBackend.get('/api/sdk_innerapi/anycross/forward', { params: { targetUrl: 'http://api.corp.com/v1/users' } });\n * axiosForBackend.post('/api/sdk_innerapi/anycross/forward', body, { params: { targetUrl: 'http://api.corp.com/v1/orders' } });\n * ```\n *\n * **注**:实际代理 / 鉴权 / 隧道由部署环境处理(如沙箱内的 mihomo 透明代理 +\n * iptables 拦截)。SDK 不感知 anycross / proxy_group_id / jwtToken。\n */\n@Module({})\nexport class HttpForwarderModule {\n static forRoot(options?: HttpForwarderModuleOptions): DynamicModule {\n const resolved = resolveOptions(options);\n return {\n module: HttpForwarderModule,\n global: true,\n controllers: [HttpForwarderController],\n providers: [\n {\n provide: HTTP_FORWARDER_MODULE_OPTIONS,\n useValue: resolved,\n },\n HttpForwarderService,\n ],\n exports: [HttpForwarderService],\n };\n }\n}\n","/** HttpForwarder module options injection token */\nexport const HTTP_FORWARDER_MODULE_OPTIONS = Symbol('HTTP_FORWARDER_MODULE_OPTIONS');\n\n/** Controller route prefix (硬编码,不可配置以保持调用方式统一) */\nexport const CONTROLLER_ROUTE = 'api/sdk_innerapi/anycross/forward';\n\n/** Default request timeout in milliseconds */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;\n\n/** Default maximum response body size in bytes (10 MB) */\nexport const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;\n\n/** Allowed target URL protocols */\nexport const ALLOWED_PROTOCOLS = ['http:', 'https:'] as const;\n\n/**\n * 入站 headers 中不能透传给上游的 hop-by-hop / transport / encoding 字段。\n * RFC 7230 §6.1 hop-by-hop + accept-encoding(由 fetch / undici 接管)。\n *\n * 注:host / content-length 由 service.buildOutgoingHeaders 在写入 fetch 入参时\n * 单点处理(host 重写为 targetUrl.host;content-length 让 fetch 自算),此处不重复。\n */\nexport const HEADERS_NOT_FORWARDED_TO_UPSTREAM = new Set([\n 'connection',\n 'keep-alive',\n 'transfer-encoding',\n 'upgrade',\n 'proxy-connection',\n 'proxy-authenticate',\n 'proxy-authorization',\n 'te',\n 'trailer',\n 'accept-encoding',\n]);\n\n/**\n * 上游响应 headers 中不应回写到下游 response 的字段。\n * - content-encoding:fetch 已解压,原 encoding 不再适用\n * - content-length:node http 会根据真实 body 重算\n * - transfer-encoding / connection / keep-alive:hop-by-hop\n */\nexport const HEADERS_NOT_RETURNED_TO_CLIENT = new Set([\n 'content-encoding',\n 'content-length',\n 'transfer-encoding',\n 'connection',\n 'keep-alive',\n 'upgrade',\n]);\n\n/** Error codes returned to callers */\nexport const ERROR_CODES = {\n INVALID_REQUEST: 'INVALID_REQUEST',\n INVALID_TARGET_PROTOCOL: 'INVALID_TARGET_PROTOCOL',\n UPSTREAM_UNREACHABLE: 'UPSTREAM_UNREACHABLE',\n UPSTREAM_TIMEOUT: 'UPSTREAM_TIMEOUT',\n RESPONSE_TOO_LARGE: 'RESPONSE_TOO_LARGE',\n} as const;\n","import {\n All,\n Controller,\n HttpException,\n HttpStatus,\n Req,\n Res,\n} from '@nestjs/common';\nimport type { Request, Response } from 'express';\nimport {\n CONTROLLER_ROUTE,\n ERROR_CODES,\n HEADERS_NOT_FORWARDED_TO_UPSTREAM,\n HEADERS_NOT_RETURNED_TO_CLIENT,\n} from '../const';\nimport { HttpForwarderService } from '../services/http-forwarder.service';\n\n/**\n * 通用内网转发 controller。\n *\n * 路径:`/api/sdk_innerapi/anycross/forward`(硬编码,所有消费方一致)。\n *\n * 接受所有 HTTP method(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)。\n * - method:直接取自入站请求自身\n * - targetUrl:取自 query `?targetUrl=...`\n * - headers:透传入站请求 headers(剔除 hop-by-hop / accept-encoding;\n * host / content-length 由 service 单点处理)\n * - body:透传入站请求 body(已经过 express body-parser,按情况序列化为字符串)\n *\n * 响应:上游 status / headers / body **直接投射到 HTTP response**,让前端 axios\n * 看到的就是真实上游响应(而非 wrap 的 JSON)。\n *\n * **SDK 不内置鉴权**——消费方在 AppModule 层面给本路由加 guard(如全局 NeedLoginGuard)。\n */\n@Controller()\nexport class HttpForwarderController {\n constructor(private readonly svc: HttpForwarderService) {}\n\n @All(CONTROLLER_ROUTE)\n async forward(@Req() req: Request, @Res() res: Response): Promise<void> {\n const targetUrl = req.query?.targetUrl;\n if (typeof targetUrl !== 'string' || !targetUrl) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_REQUEST,\n message: 'query parameter `targetUrl` is required',\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n\n const out = await this.svc.forward({\n method: req.method.toUpperCase(),\n targetUrl,\n headers: HttpForwarderController.pickIncomingHeaders(req.headers),\n body: HttpForwarderController.serializeIncomingBody(req),\n });\n\n res.status(out.status);\n for (const [key, value] of Object.entries(out.headers)) {\n if (HEADERS_NOT_RETURNED_TO_CLIENT.has(key.toLowerCase())) continue;\n res.setHeader(key, value);\n }\n res.send(out.body);\n }\n\n /**\n * 从入站请求 headers 选出可透传给上游的字段。剔除 hop-by-hop / accept-encoding。\n * 保留 cookie / authorization / x-* 等业务头。\n * host / content-length 不在此处剥离——由 service.buildOutgoingHeaders 单点处理。\n */\n static pickIncomingHeaders(incoming: Request['headers']): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, raw] of Object.entries(incoming)) {\n if (HEADERS_NOT_FORWARDED_TO_UPSTREAM.has(key.toLowerCase())) continue;\n if (raw === undefined) continue;\n out[key] = Array.isArray(raw) ? raw.join(', ') : String(raw);\n }\n return out;\n }\n\n /**\n * 把 express 已解析的 body 序列化回字符串供 service 透传。\n */\n static serializeIncomingBody(req: Request): string | null {\n const method = req.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return null;\n const body = (req as { body?: unknown }).body;\n if (body === undefined || body === null) return null;\n if (typeof body === 'string') return body;\n if (Buffer.isBuffer(body)) return body.toString('utf-8');\n if (typeof body === 'object' && Object.keys(body as object).length === 0) {\n return null;\n }\n return JSON.stringify(body);\n }\n}\n","import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';\nimport {\n ALLOWED_PROTOCOLS,\n ERROR_CODES,\n HTTP_FORWARDER_MODULE_OPTIONS,\n} from '../const';\nimport type { ForwardRequestDto, ForwardResponseDto } from '../types';\nimport type { HttpForwarderOptionsResolved } from './options.resolved';\n\n/**\n * 反向 HTTP 转发器:把入站请求\"原样\"转发到目标 URL。\n *\n * 用途:把请求的出口从浏览器变成 node。客户内网访问的代理 / 鉴权 / 隧道由\n * 部署环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)负责,SDK 不感知。\n *\n * 只做 5 件核心事:\n * 1. 协议白名单(仅 http/https,防 SSRF)\n * 2. host 重写 + content-length 剥离(语义正确性,单点处理)\n * 3. 单次请求超时(AbortController,默认 30s)\n * 4. 响应体大小上限(流式累计字节,防 OOM,默认 10MB)\n * 5. 错误码映射(给调用方稳定的 error contract)\n */\n@Injectable()\nexport class HttpForwarderService {\n constructor(\n @Inject(HTTP_FORWARDER_MODULE_OPTIONS)\n private readonly options: HttpForwarderOptionsResolved,\n ) {}\n\n async forward(payload: ForwardRequestDto): Promise<ForwardResponseDto> {\n const targetUrl = this.assertValidTargetUrl(payload?.targetUrl);\n const headers = this.buildOutgoingHeaders(payload.headers, targetUrl);\n const controller = new AbortController();\n const timeoutTimer = setTimeout(\n () => controller.abort(),\n this.options.requestTimeoutMs,\n );\n if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();\n\n try {\n const response = await fetch(targetUrl.toString(), {\n method: payload.method,\n headers,\n body: payload.body ?? undefined,\n signal: controller.signal,\n });\n const body = await this.readBoundedBody(response, controller);\n return {\n status: response.status,\n headers: Object.fromEntries(response.headers),\n body,\n };\n } catch (err) {\n throw this.mapNetworkError(err, controller);\n } finally {\n clearTimeout(timeoutTimer);\n }\n }\n\n /**\n * URL 解析 + 协议白名单。其他入参校验交给 fetch 自身。\n */\n private assertValidTargetUrl(raw: unknown): URL {\n if (typeof raw !== 'string' || !raw) {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'targetUrl is required' },\n HttpStatus.BAD_REQUEST,\n );\n }\n let parsed: URL;\n try {\n parsed = new URL(raw);\n } catch {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'Invalid targetUrl' },\n HttpStatus.BAD_REQUEST,\n );\n }\n if (\n !ALLOWED_PROTOCOLS.includes(parsed.protocol as (typeof ALLOWED_PROTOCOLS)[number])\n ) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_TARGET_PROTOCOL,\n message: `Unsupported protocol: ${parsed.protocol}`,\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n return parsed;\n }\n\n /**\n * host / content-length 的**单点处理点**:\n * - host:强制覆盖为 targetUrl.host(防上游误识别,无论调用方传什么)\n * - content-length:剥离(fetch 会按真实 body 自算)\n *\n * 其他 hop-by-hop / accept-encoding 等由 controller 已剥离,service 信任入参。\n */\n private buildOutgoingHeaders(\n incoming: Record<string, string> | undefined,\n targetUrl: URL,\n ): Record<string, string> {\n const out: Record<string, string> = {};\n if (incoming) {\n for (const [key, value] of Object.entries(incoming)) {\n const lower = key.toLowerCase();\n if (lower === 'host' || lower === 'content-length') continue;\n out[key] = value;\n }\n }\n out['host'] = targetUrl.host;\n return out;\n }\n\n /**\n * 流式读取响应体并累计字节数,超 `maxResponseBytes` 即 abort + 抛 502。\n */\n private async readBoundedBody(\n response: Response,\n controller: AbortController,\n ): Promise<string> {\n const limit = this.options.maxResponseBytes;\n if (!response.body) return '';\n const reader = response.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n total += value.byteLength;\n if (total > limit) {\n controller.abort();\n throw new HttpException(\n {\n code: ERROR_CODES.RESPONSE_TOO_LARGE,\n message: `response exceeds maxResponseBytes (${limit})`,\n },\n HttpStatus.BAD_GATEWAY,\n );\n }\n chunks.push(value);\n }\n }\n } finally {\n void reader.cancel().catch(() => undefined);\n }\n return Buffer.concat(chunks).toString('utf-8');\n }\n\n /**\n * 把 fetch 抛错 / 超时统一映射为 HttpException。\n *\n * 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)\n * 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504\n * 3. TypeError → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)\n * 4. 兜底 → UPSTREAM_UNREACHABLE 502\n */\n private mapNetworkError(err: unknown, controller: AbortController): HttpException {\n if (err instanceof HttpException) return err;\n if (controller.signal.aborted) {\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_TIMEOUT, message: 'upstream timeout' },\n HttpStatus.GATEWAY_TIMEOUT,\n );\n }\n if (err instanceof TypeError) {\n return new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'invalid request configuration' },\n HttpStatus.BAD_REQUEST,\n );\n }\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_UNREACHABLE, message: 'upstream unreachable' },\n HttpStatus.BAD_GATEWAY,\n );\n }\n}\n","import type { HttpForwarderModuleOptions } from '../types';\nimport {\n DEFAULT_MAX_RESPONSE_BYTES,\n DEFAULT_REQUEST_TIMEOUT_MS,\n} from '../const';\n\n/**\n * forRoot 入参经过默认值合并后的形态。SDK 内部统一使用此类型。\n */\nexport interface HttpForwarderOptionsResolved\n extends Required<HttpForwarderModuleOptions> {}\n\n/**\n * 校验并补默认值。配置错误(非法字段值)在模块装配时抛出,启动期 fail-fast。\n */\nexport function resolveOptions(\n options: HttpForwarderModuleOptions | undefined,\n): HttpForwarderOptionsResolved {\n const opts = options ?? {};\n const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;\n\n if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `requestTimeoutMs` must be a positive integer',\n );\n }\n if (!Number.isInteger(maxResponseBytes) || maxResponseBytes <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `maxResponseBytes` must be a positive integer',\n );\n }\n return { requestTimeoutMs, maxResponseBytes };\n}\n"],"mappings":"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","names":["Module","HTTP_FORWARDER_MODULE_OPTIONS","Symbol","CONTROLLER_ROUTE","DEFAULT_REQUEST_TIMEOUT_MS","DEFAULT_MAX_RESPONSE_BYTES","ALLOWED_PROTOCOLS","HEADERS_NOT_FORWARDED_TO_UPSTREAM","Set","HEADERS_NOT_RETURNED_TO_CLIENT","ERROR_CODES","INVALID_REQUEST","INVALID_TARGET_PROTOCOL","UPSTREAM_UNREACHABLE","UPSTREAM_TIMEOUT","RESPONSE_TOO_LARGE","All","Controller","HttpException","HttpStatus","Req","Res","HttpException","HttpStatus","Inject","Injectable","HttpForwarderService","options","forward","payload","targetUrl","assertValidTargetUrl","headers","buildOutgoingHeaders","controller","AbortController","timeoutTimer","setTimeout","abort","requestTimeoutMs","unref","response","fetch","toString","method","body","undefined","signal","readBoundedBody","status","Object","fromEntries","err","mapNetworkError","clearTimeout","raw","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","parsed","URL","ALLOWED_PROTOCOLS","includes","protocol","INVALID_TARGET_PROTOCOL","incoming","out","key","value","entries","lower","toLowerCase","host","limit","maxResponseBytes","reader","getReader","chunks","total","done","read","byteLength","RESPONSE_TOO_LARGE","BAD_GATEWAY","push","cancel","catch","Buffer","concat","aborted","UPSTREAM_TIMEOUT","GATEWAY_TIMEOUT","TypeError","UPSTREAM_UNREACHABLE","HttpForwarderController","svc","forward","req","res","targetUrl","query","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","out","method","toUpperCase","headers","pickIncomingHeaders","body","serializeIncomingBody","status","key","value","Object","entries","HEADERS_NOT_RETURNED_TO_CLIENT","has","toLowerCase","setHeader","send","incoming","raw","HEADERS_NOT_FORWARDED_TO_UPSTREAM","undefined","Array","isArray","join","String","Buffer","isBuffer","toString","keys","length","JSON","stringify","resolveOptions","options","opts","requestTimeoutMs","DEFAULT_REQUEST_TIMEOUT_MS","maxResponseBytes","DEFAULT_MAX_RESPONSE_BYTES","Number","isInteger","Error","HttpForwarderModule","forRoot","options","resolved","resolveOptions","module","global","controllers","HttpForwarderController","providers","provide","HTTP_FORWARDER_MODULE_OPTIONS","useValue","HttpForwarderService","exports"]}
|
|
1
|
+
{"version":3,"sources":["../src/http-forwarder.module.ts","../src/const.ts","../src/controllers/http-forwarder.controller.ts","../src/services/http-forwarder.service.ts","../src/services/options.resolved.ts"],"sourcesContent":["import { DynamicModule, Module } from '@nestjs/common';\nimport { HTTP_FORWARDER_MODULE_OPTIONS } from './const';\nimport { HttpForwarderController } from './controllers/http-forwarder.controller';\nimport { HttpForwarderService } from './services/http-forwarder.service';\nimport { resolveOptions } from './services/options.resolved';\nimport type { HttpForwarderModuleOptions } from './types';\n\n/**\n * HttpForwarderModule\n *\n * 自动注册:\n * - `HttpForwarderController` —— 通用内网转发 endpoint `ALL /api/sdk_innerapi/anycross/forward?targetUrl=...`\n * 支持所有 HTTP method,headers / body 自动透传,上游响应直接投射到客户端。\n * - `HttpForwarderService`(可注入到自定义 controller,编排特殊场景)\n *\n * **不内置鉴权**——消费方在 AppModule 层面给路由加 guard(如全局 NeedLoginGuard)。\n *\n * 使用示例:\n * ```ts\n * @Module({\n * imports: [\n * HttpForwarderModule.forRoot({\n * requestTimeoutMs: 30_000,\n * maxResponseBytes: 10 * 1024 * 1024,\n * }),\n * ],\n * })\n * export class AppModule {}\n * ```\n *\n * 前端调用:\n * ```ts\n * axiosForBackend.get('/api/sdk_innerapi/anycross/forward', { params: { targetUrl: 'http://api.corp.com/v1/users' } });\n * axiosForBackend.post('/api/sdk_innerapi/anycross/forward', body, { params: { targetUrl: 'http://api.corp.com/v1/orders' } });\n * ```\n *\n * **注**:实际代理 / 鉴权 / 隧道由部署环境处理(如沙箱内的 mihomo 透明代理 +\n * iptables 拦截)。SDK 不感知 anycross / proxy_group_id / jwtToken。\n */\n@Module({})\nexport class HttpForwarderModule {\n static forRoot(options?: HttpForwarderModuleOptions): DynamicModule {\n const resolved = resolveOptions(options);\n return {\n module: HttpForwarderModule,\n global: true,\n controllers: [HttpForwarderController],\n providers: [\n {\n provide: HTTP_FORWARDER_MODULE_OPTIONS,\n useValue: resolved,\n },\n HttpForwarderService,\n ],\n exports: [HttpForwarderService],\n };\n }\n}\n","/** HttpForwarder module options injection token */\nexport const HTTP_FORWARDER_MODULE_OPTIONS = Symbol('HTTP_FORWARDER_MODULE_OPTIONS');\n\n/** Controller route prefix (硬编码,不可配置以保持调用方式统一) */\nexport const CONTROLLER_ROUTE = 'api/sdk_innerapi/anycross/forward';\n\n/** Default request timeout in milliseconds */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;\n\n/** Default maximum response body size in bytes (10 MB) */\nexport const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;\n\n/** Allowed target URL protocols */\nexport const ALLOWED_PROTOCOLS = ['http:', 'https:'] as const;\n\n/**\n * 入站 headers 中不能透传给上游的 hop-by-hop / transport / encoding 字段。\n * RFC 7230 §6.1 hop-by-hop + accept-encoding(由 fetch / undici 接管)。\n *\n * 注:host / content-length 由 service.buildOutgoingHeaders 在写入 fetch 入参时\n * 单点处理(host 重写为 targetUrl.host;content-length 让 fetch 自算),此处不重复。\n */\nexport const HEADERS_NOT_FORWARDED_TO_UPSTREAM = new Set([\n 'connection',\n 'keep-alive',\n 'transfer-encoding',\n 'upgrade',\n 'proxy-connection',\n 'proxy-authenticate',\n 'proxy-authorization',\n 'te',\n 'trailer',\n 'accept-encoding',\n]);\n\n/**\n * 上游响应 headers 中不应回写到下游 response 的字段。\n * - content-encoding:fetch 已解压,原 encoding 不再适用\n * - content-length:node http 会根据真实 body 重算\n * - transfer-encoding / connection / keep-alive:hop-by-hop\n */\nexport const HEADERS_NOT_RETURNED_TO_CLIENT = new Set([\n 'content-encoding',\n 'content-length',\n 'transfer-encoding',\n 'connection',\n 'keep-alive',\n 'upgrade',\n]);\n\n/** Error codes returned to callers */\nexport const ERROR_CODES = {\n INVALID_REQUEST: 'INVALID_REQUEST',\n INVALID_TARGET_PROTOCOL: 'INVALID_TARGET_PROTOCOL',\n UPSTREAM_UNREACHABLE: 'UPSTREAM_UNREACHABLE',\n UPSTREAM_TIMEOUT: 'UPSTREAM_TIMEOUT',\n RESPONSE_TOO_LARGE: 'RESPONSE_TOO_LARGE',\n} as const;\n","import {\n All,\n Controller,\n HttpException,\n HttpStatus,\n Req,\n Res,\n} from '@nestjs/common';\nimport type { Request, Response } from 'express';\nimport {\n CONTROLLER_ROUTE,\n ERROR_CODES,\n HEADERS_NOT_FORWARDED_TO_UPSTREAM,\n HEADERS_NOT_RETURNED_TO_CLIENT,\n} from '../const';\nimport { HttpForwarderService } from '../services/http-forwarder.service';\n\n/**\n * 通用内网转发 controller。\n *\n * 路径:`/api/sdk_innerapi/anycross/forward`(硬编码,所有消费方一致)。\n *\n * 接受所有 HTTP method(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)。\n * - method:直接取自入站请求自身\n * - targetUrl:取自 query `?targetUrl=...`\n * - headers:透传入站请求 headers(剔除 hop-by-hop / accept-encoding;\n * host / content-length 由 service 单点处理)\n * - body:透传入站请求 body(已经过 express body-parser,按情况序列化为字符串)\n *\n * 响应:上游 status / headers / body **直接投射到 HTTP response**,让前端 axios\n * 看到的就是真实上游响应(而非 wrap 的 JSON)。\n *\n * **SDK 不内置鉴权**——消费方在 AppModule 层面给本路由加 guard(如全局 NeedLoginGuard)。\n */\n@Controller()\nexport class HttpForwarderController {\n constructor(private readonly svc: HttpForwarderService) {}\n\n @All(CONTROLLER_ROUTE)\n async forward(@Req() req: Request, @Res() res: Response): Promise<void> {\n const targetUrl = req.query?.targetUrl;\n if (typeof targetUrl !== 'string' || !targetUrl) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_REQUEST,\n message: 'query parameter `targetUrl` is required',\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n\n const out = await this.svc.forward({\n method: req.method.toUpperCase(),\n targetUrl,\n headers: HttpForwarderController.pickIncomingHeaders(req.headers),\n body: HttpForwarderController.serializeIncomingBody(req),\n });\n\n res.status(out.status);\n for (const [key, value] of Object.entries(out.headers)) {\n if (HEADERS_NOT_RETURNED_TO_CLIENT.has(key.toLowerCase())) continue;\n res.setHeader(key, value);\n }\n res.send(out.body);\n }\n\n /**\n * 从入站请求 headers 选出可透传给上游的字段。剔除 hop-by-hop / accept-encoding。\n * 保留 cookie / authorization / x-* 等业务头。\n * host / content-length 不在此处剥离——由 service.buildOutgoingHeaders 单点处理。\n */\n static pickIncomingHeaders(incoming: Request['headers']): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, raw] of Object.entries(incoming)) {\n if (HEADERS_NOT_FORWARDED_TO_UPSTREAM.has(key.toLowerCase())) continue;\n if (raw === undefined) continue;\n out[key] = Array.isArray(raw) ? raw.join(', ') : String(raw);\n }\n return out;\n }\n\n /**\n * 把 express 已解析的 body 序列化回字符串供 service 透传。\n */\n static serializeIncomingBody(req: Request): string | null {\n const method = req.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return null;\n const body = (req as { body?: unknown }).body;\n if (body === undefined || body === null) return null;\n if (typeof body === 'string') return body;\n if (Buffer.isBuffer(body)) return body.toString('utf-8');\n if (typeof body === 'object' && Object.keys(body as object).length === 0) {\n return null;\n }\n return JSON.stringify(body);\n }\n}\n","import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';\nimport {\n ALLOWED_PROTOCOLS,\n ERROR_CODES,\n HTTP_FORWARDER_MODULE_OPTIONS,\n} from '../const';\nimport type { ForwardRequestDto, ForwardResponseDto } from '../types';\nimport type { HttpForwarderOptionsResolved } from './options.resolved';\n\n/**\n * 反向 HTTP 转发器:把入站请求\"原样\"转发到目标 URL。\n *\n * 用途:把请求的出口从浏览器变成 node。客户内网访问的代理 / 鉴权 / 隧道由\n * 部署环境(如沙箱内的 mihomo 透明代理 + iptables 拦截)负责,SDK 不感知。\n *\n * 只做 5 件核心事:\n * 1. 协议白名单(仅 http/https,防 SSRF)\n * 2. host 重写 + content-length 剥离(语义正确性,单点处理)\n * 3. 单次请求超时(AbortController,默认 30s)\n * 4. 响应体大小上限(流式累计字节,防 OOM,默认 10MB)\n * 5. 错误码映射(给调用方稳定的 error contract)\n */\n@Injectable()\nexport class HttpForwarderService {\n constructor(\n @Inject(HTTP_FORWARDER_MODULE_OPTIONS)\n private readonly options: HttpForwarderOptionsResolved,\n ) {}\n\n async forward(payload: ForwardRequestDto): Promise<ForwardResponseDto> {\n const targetUrl = this.assertValidTargetUrl(payload?.targetUrl);\n const headers = this.buildOutgoingHeaders(payload.headers, targetUrl);\n const controller = new AbortController();\n const timeoutTimer = setTimeout(\n () => controller.abort(),\n this.options.requestTimeoutMs,\n );\n if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();\n\n try {\n const response = await fetch(targetUrl.toString(), {\n method: payload.method,\n headers,\n body: payload.body ?? undefined,\n signal: controller.signal,\n });\n const body = await this.readBoundedBody(response, controller);\n return {\n status: response.status,\n headers: Object.fromEntries(response.headers),\n body,\n };\n } catch (err) {\n throw this.mapNetworkError(err, controller);\n } finally {\n clearTimeout(timeoutTimer);\n }\n }\n\n /**\n * URL 解析 + 协议白名单。其他入参校验交给 fetch 自身。\n */\n private assertValidTargetUrl(raw: unknown): URL {\n if (typeof raw !== 'string' || !raw) {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'targetUrl is required' },\n HttpStatus.BAD_REQUEST,\n );\n }\n let parsed: URL;\n try {\n parsed = new URL(raw);\n } catch {\n throw new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'Invalid targetUrl' },\n HttpStatus.BAD_REQUEST,\n );\n }\n if (\n !ALLOWED_PROTOCOLS.includes(parsed.protocol as (typeof ALLOWED_PROTOCOLS)[number])\n ) {\n throw new HttpException(\n {\n code: ERROR_CODES.INVALID_TARGET_PROTOCOL,\n message: `Unsupported protocol: ${parsed.protocol}`,\n },\n HttpStatus.BAD_REQUEST,\n );\n }\n return parsed;\n }\n\n /**\n * host / content-length 的**单点处理点**:\n * - host:强制覆盖为 targetUrl.host(防上游误识别,无论调用方传什么)\n * - content-length:剥离(fetch 会按真实 body 自算)\n *\n * 其他 hop-by-hop / accept-encoding 等由 controller 已剥离,service 信任入参。\n */\n private buildOutgoingHeaders(\n incoming: Record<string, string> | undefined,\n targetUrl: URL,\n ): Record<string, string> {\n const out: Record<string, string> = {};\n if (incoming) {\n for (const [key, value] of Object.entries(incoming)) {\n const lower = key.toLowerCase();\n if (lower === 'host' || lower === 'content-length') continue;\n out[key] = value;\n }\n }\n out['host'] = targetUrl.host;\n return out;\n }\n\n /**\n * 流式读取响应体并累计字节数,超 `maxResponseBytes` 即 abort + 抛 502。\n */\n private async readBoundedBody(\n response: Response,\n controller: AbortController,\n ): Promise<string> {\n const limit = this.options.maxResponseBytes;\n if (!response.body) return '';\n const reader = response.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n total += value.byteLength;\n if (total > limit) {\n controller.abort();\n throw new HttpException(\n {\n code: ERROR_CODES.RESPONSE_TOO_LARGE,\n message: `response exceeds maxResponseBytes (${limit})`,\n },\n HttpStatus.BAD_GATEWAY,\n );\n }\n chunks.push(value);\n }\n }\n } finally {\n void reader.cancel().catch(() => undefined);\n }\n return Buffer.concat(chunks).toString('utf-8');\n }\n\n /**\n * 把 fetch 抛错 / 超时统一映射为 HttpException。\n *\n * 1. HttpException → 直接透传(含 INVALID_TARGET_PROTOCOL / RESPONSE_TOO_LARGE)\n * 2. controller.signal.aborted → UPSTREAM_TIMEOUT 504\n * 3. TypeError + err.cause.code 是网络层错误 → UPSTREAM_UNREACHABLE 502\n * (ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / ETIMEDOUT / ENETUNREACH / EAI_AGAIN)\n * 4. TypeError 兜底 → INVALID_REQUEST 400(fetch 对配置错统一抛 TypeError)\n * 5. 兜底 → UPSTREAM_UNREACHABLE 502\n */\n private mapNetworkError(err: unknown, controller: AbortController): HttpException {\n if (err instanceof HttpException) return err;\n if (controller.signal.aborted) {\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_TIMEOUT, message: 'upstream timeout' },\n HttpStatus.GATEWAY_TIMEOUT,\n );\n }\n if (err instanceof TypeError) {\n const causeCode = HttpForwarderService.extractCauseCode(err);\n if (causeCode && NETWORK_ERROR_CAUSE_CODES.has(causeCode)) {\n return new HttpException(\n {\n code: ERROR_CODES.UPSTREAM_UNREACHABLE,\n message: `upstream unreachable (${causeCode})`,\n },\n HttpStatus.BAD_GATEWAY,\n );\n }\n return new HttpException(\n { code: ERROR_CODES.INVALID_REQUEST, message: 'invalid request configuration' },\n HttpStatus.BAD_REQUEST,\n );\n }\n return new HttpException(\n { code: ERROR_CODES.UPSTREAM_UNREACHABLE, message: 'upstream unreachable' },\n HttpStatus.BAD_GATEWAY,\n );\n }\n\n /**\n * 从 fetch 抛出的 TypeError 中提取 cause.code(node fetch 的 undici 实现把网络错误\n * 包到 err.cause 上)。\n */\n private static extractCauseCode(err: TypeError): string | undefined {\n const cause = (err as TypeError & { cause?: unknown }).cause;\n if (cause && typeof cause === 'object' && 'code' in cause) {\n const code = (cause as { code: unknown }).code;\n return typeof code === 'string' ? code : undefined;\n }\n return undefined;\n }\n}\n\n/**\n * fetch 抛 TypeError 时,cause.code 命中此集合视为网络层不可达,\n * 映射为 502 UPSTREAM_UNREACHABLE;其他视为请求配置错 400 INVALID_REQUEST。\n */\nconst NETWORK_ERROR_CAUSE_CODES: ReadonlySet<string> = new Set([\n 'ENOTFOUND',\n 'ECONNREFUSED',\n 'EHOSTUNREACH',\n 'ETIMEDOUT',\n 'ENETUNREACH',\n 'EAI_AGAIN',\n 'ECONNRESET',\n]);\n","import type { HttpForwarderModuleOptions } from '../types';\nimport {\n DEFAULT_MAX_RESPONSE_BYTES,\n DEFAULT_REQUEST_TIMEOUT_MS,\n} from '../const';\n\n/**\n * forRoot 入参经过默认值合并后的形态。SDK 内部统一使用此类型。\n */\nexport interface HttpForwarderOptionsResolved\n extends Required<HttpForwarderModuleOptions> {}\n\n/**\n * 校验并补默认值。配置错误(非法字段值)在模块装配时抛出,启动期 fail-fast。\n */\nexport function resolveOptions(\n options: HttpForwarderModuleOptions | undefined,\n): HttpForwarderOptionsResolved {\n const opts = options ?? {};\n const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;\n\n if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `requestTimeoutMs` must be a positive integer',\n );\n }\n if (!Number.isInteger(maxResponseBytes) || maxResponseBytes <= 0) {\n throw new Error(\n 'HttpForwarderModule.forRoot: `maxResponseBytes` must be a positive integer',\n );\n }\n return { requestTimeoutMs, maxResponseBytes };\n}\n"],"mappings":"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","names":["Module","HTTP_FORWARDER_MODULE_OPTIONS","Symbol","CONTROLLER_ROUTE","DEFAULT_REQUEST_TIMEOUT_MS","DEFAULT_MAX_RESPONSE_BYTES","ALLOWED_PROTOCOLS","HEADERS_NOT_FORWARDED_TO_UPSTREAM","Set","HEADERS_NOT_RETURNED_TO_CLIENT","ERROR_CODES","INVALID_REQUEST","INVALID_TARGET_PROTOCOL","UPSTREAM_UNREACHABLE","UPSTREAM_TIMEOUT","RESPONSE_TOO_LARGE","All","Controller","HttpException","HttpStatus","Req","Res","HttpException","HttpStatus","Inject","Injectable","HttpForwarderService","options","forward","payload","targetUrl","assertValidTargetUrl","headers","buildOutgoingHeaders","controller","AbortController","timeoutTimer","setTimeout","abort","requestTimeoutMs","unref","response","fetch","toString","method","body","undefined","signal","readBoundedBody","status","Object","fromEntries","err","mapNetworkError","clearTimeout","raw","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","parsed","URL","ALLOWED_PROTOCOLS","includes","protocol","INVALID_TARGET_PROTOCOL","incoming","out","key","value","entries","lower","toLowerCase","host","limit","maxResponseBytes","reader","getReader","chunks","total","done","read","byteLength","RESPONSE_TOO_LARGE","BAD_GATEWAY","push","cancel","catch","Buffer","concat","aborted","UPSTREAM_TIMEOUT","GATEWAY_TIMEOUT","TypeError","causeCode","extractCauseCode","NETWORK_ERROR_CAUSE_CODES","has","UPSTREAM_UNREACHABLE","cause","Set","HttpForwarderController","svc","forward","req","res","targetUrl","query","HttpException","code","ERROR_CODES","INVALID_REQUEST","message","HttpStatus","BAD_REQUEST","out","method","toUpperCase","headers","pickIncomingHeaders","body","serializeIncomingBody","status","key","value","Object","entries","HEADERS_NOT_RETURNED_TO_CLIENT","has","toLowerCase","setHeader","send","incoming","raw","HEADERS_NOT_FORWARDED_TO_UPSTREAM","undefined","Array","isArray","join","String","Buffer","isBuffer","toString","keys","length","JSON","stringify","resolveOptions","options","opts","requestTimeoutMs","DEFAULT_REQUEST_TIMEOUT_MS","maxResponseBytes","DEFAULT_MAX_RESPONSE_BYTES","Number","isInteger","Error","HttpForwarderModule","forRoot","options","resolved","resolveOptions","module","global","controllers","HttpForwarderController","providers","provide","HTTP_FORWARDER_MODULE_OPTIONS","useValue","HttpForwarderService","exports"]}
|
package/package.json
CHANGED