Skip to content

Commit 3f1c2a4

Browse files
committed
refactor: switch HTTP layer to undici with connection pooling
Replace node:http with undici for HTTP requests. A shared Agent manages per-origin connection pools with keep-alive, so sequential requests to the same device reuse TCP connections. Granular timeouts (connect, headers, body) are derived from the caller's overall timeout budget.
1 parent e93c14c commit 3f1c2a4

4 files changed

Lines changed: 74 additions & 50 deletions

File tree

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,13 @@
5858
"ajv-formats": "^3.0.1",
5959
"chalk": "^5.4.1",
6060
"semver": "^7.7.4",
61-
"tplink-smarthome-api": "~5.0.0"
61+
"tplink-smarthome-api": "~5.0.0",
62+
"undici": "^7.24.6"
6263
},
6364
"devDependencies": {
65+
"@biomejs/biome": "2.4.8",
6466
"@types/node": "^22.19.15",
6567
"@types/semver": "^7.7.1",
66-
"@biomejs/biome": "2.4.8",
6768
"cspell": "^9.7.0",
6869
"hap-nodejs": "~0.14.2",
6970
"homebridge": "~1.11.3",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/klap/http.ts

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,74 @@
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>;
213

314
export interface HttpResponse {
415
statusCode: number;
5-
headers: http.IncomingHttpHeaders;
16+
headers: HttpHeaders;
617
body: Buffer;
718
}
819

920
/**
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.
1141
*/
12-
export function httpPost(
42+
export async function httpPost(
1343
url: string,
1444
body: Buffer | string,
1545
headers: Record<string, string>,
1646
timeoutMs: number,
1747
): 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),
5562
});
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+
};
5674
}

src/klap/transport-utils.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type * as http from "node:http";
1+
import type { HttpHeaders } from "./http.js";
22

33
/**
44
* Parse TP_SESSIONID from Set-Cookie header(s).
@@ -7,9 +7,7 @@ import type * as http from "node:http";
77
* TP_SESSIONID or SESSIONID and return the raw cookie string suitable
88
* for sending back in a Cookie header.
99
*/
10-
export function parseSessionCookie(
11-
headers: http.IncomingHttpHeaders,
12-
): string | undefined {
10+
export function parseSessionCookie(headers: HttpHeaders): string | undefined {
1311
const raw = headers["set-cookie"];
1412
if (!raw) return undefined;
1513

@@ -26,9 +24,7 @@ export function parseSessionCookie(
2624
/**
2725
* Parse the TIMEOUT value from Set-Cookie headers (seconds).
2826
*/
29-
export function parseTimeoutCookie(
30-
headers: http.IncomingHttpHeaders,
31-
): number | undefined {
27+
export function parseTimeoutCookie(headers: HttpHeaders): number | undefined {
3228
const raw = headers["set-cookie"];
3329
if (!raw) return undefined;
3430

0 commit comments

Comments
 (0)