|
1 | | -import * as http from "node:http"; |
| 1 | +import { Agent, request } from "undici"; |
| 2 | + |
| 3 | +const CONNECT_TIMEOUT_MS = 3_000; |
| 4 | + |
| 5 | +const agent = new Agent({ |
| 6 | + connect: { timeout: CONNECT_TIMEOUT_MS }, |
| 7 | + keepAliveTimeout: 30_000, |
| 8 | + keepAliveMaxTimeout: 60_000, |
| 9 | + pipelining: 1, |
| 10 | +}); |
| 11 | + |
| 12 | +export type HttpHeaders = Record<string, string | string[] | undefined>; |
2 | 13 |
|
3 | 14 | export interface HttpResponse { |
4 | 15 | statusCode: number; |
5 | | - headers: http.IncomingHttpHeaders; |
| 16 | + headers: HttpHeaders; |
6 | 17 | body: Buffer; |
7 | 18 | } |
8 | 19 |
|
9 | 20 | /** |
10 | | - * Simple HTTP POST using Node's built-in http module. |
| 21 | + * Derive granular timeouts from an overall timeout budget. |
| 22 | + * |
| 23 | + * - headersTimeout: 60% of overall (time to receive response headers after request sent) |
| 24 | + * - bodyTimeout: 80% of overall (max time between body data chunks) |
| 25 | + * |
| 26 | + * The overall timeout is still enforced as a hard deadline via AbortSignal. |
| 27 | + */ |
| 28 | +function deriveTimeouts(overallMs: number) { |
| 29 | + return { |
| 30 | + headersTimeout: Math.round(overallMs * 0.6), |
| 31 | + bodyTimeout: Math.round(overallMs * 0.8), |
| 32 | + }; |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * HTTP POST using undici with connection pooling and granular timeouts. |
| 37 | + * |
| 38 | + * Connections to the same origin are reused via keep-alive. Connect timeout |
| 39 | + * is fixed at 3s (suitable for local network IoT devices). Per-request |
| 40 | + * headers and body timeouts are derived from the caller's overall timeout. |
11 | 41 | */ |
12 | | -export function httpPost( |
| 42 | +export async function httpPost( |
13 | 43 | url: string, |
14 | 44 | body: Buffer | string, |
15 | 45 | headers: Record<string, string>, |
16 | 46 | timeoutMs: number, |
17 | 47 | ): Promise<HttpResponse> { |
18 | | - return new Promise((resolve, reject) => { |
19 | | - const parsed = new URL(url); |
20 | | - const reqBody = typeof body === "string" ? Buffer.from(body, "utf-8") : body; |
21 | | - |
22 | | - const req = http.request( |
23 | | - { |
24 | | - hostname: parsed.hostname, |
25 | | - port: parsed.port || 80, |
26 | | - path: parsed.pathname + parsed.search, |
27 | | - method: "POST", |
28 | | - headers: { |
29 | | - ...headers, |
30 | | - "Content-Length": String(reqBody.length), |
31 | | - }, |
32 | | - timeout: timeoutMs, |
33 | | - }, |
34 | | - (res) => { |
35 | | - const chunks: Buffer[] = []; |
36 | | - res.on("data", (chunk: Buffer) => chunks.push(chunk)); |
37 | | - res.on("end", () => { |
38 | | - resolve({ |
39 | | - statusCode: res.statusCode ?? 0, |
40 | | - headers: res.headers, |
41 | | - body: Buffer.concat(chunks), |
42 | | - }); |
43 | | - }); |
44 | | - res.on("error", reject); |
45 | | - }, |
46 | | - ); |
47 | | - |
48 | | - req.on("error", reject); |
49 | | - req.on("timeout", () => { |
50 | | - req.destroy(new Error(`HTTP request timed out after ${timeoutMs}ms`)); |
51 | | - }); |
52 | | - |
53 | | - req.write(reqBody); |
54 | | - req.end(); |
| 48 | + const reqBody = typeof body === "string" ? Buffer.from(body, "utf-8") : body; |
| 49 | + const { headersTimeout, bodyTimeout } = deriveTimeouts(timeoutMs); |
| 50 | + |
| 51 | + const resp = await request(url, { |
| 52 | + method: "POST", |
| 53 | + headers: { |
| 54 | + ...headers, |
| 55 | + "Content-Length": String(reqBody.length), |
| 56 | + }, |
| 57 | + body: reqBody, |
| 58 | + dispatcher: agent, |
| 59 | + headersTimeout, |
| 60 | + bodyTimeout, |
| 61 | + signal: AbortSignal.timeout(timeoutMs), |
55 | 62 | }); |
| 63 | + |
| 64 | + const chunks: Buffer[] = []; |
| 65 | + for await (const chunk of resp.body) { |
| 66 | + chunks.push(Buffer.from(chunk)); |
| 67 | + } |
| 68 | + |
| 69 | + return { |
| 70 | + statusCode: resp.statusCode, |
| 71 | + headers: resp.headers as HttpHeaders, |
| 72 | + body: Buffer.concat(chunks), |
| 73 | + }; |
56 | 74 | } |
0 commit comments