@compforge/harness-toolbox 0.4.8 → 0.5.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/README.md +41 -9
- package/dist/{chunk-1ts7pswj.js → chunk-0d8vs5q8.js} +33 -2
- package/dist/{chunk-h8fwx0k7.js → chunk-18gcp0ck.js} +32 -7
- package/dist/{chunk-35cjec9z.js → chunk-3b877a0y.js} +6 -6
- package/dist/{chunk-gm4swndx.js → chunk-43mcyrh4.js} +14 -24
- package/dist/chunk-56fev9np.js +142 -0
- package/dist/{chunk-zp1s833x.js → chunk-7m95c7pn.js} +26 -13
- package/dist/{chunk-f3vq900j.js → chunk-92aacbwh.js} +1 -1
- package/dist/{chunk-x4ntb898.js → chunk-c2b2jwm4.js} +18 -18
- package/dist/chunk-ehmx0yhp.js +52 -0
- package/dist/{chunk-t4hrnaj0.js → chunk-ex429qjt.js} +22 -20
- package/dist/{chunk-m391jmpw.js → chunk-hbhbragw.js} +9 -9
- package/dist/{chunk-fqrknvr8.js → chunk-kap6ka3n.js} +27 -12
- package/dist/{chunk-vymm4g9h.js → chunk-kxpecgy7.js} +106 -37
- package/dist/{chunk-nftzckdy.js → chunk-mvkxs8d0.js} +17 -2
- package/dist/{chunk-f98qrpx1.js → chunk-njs48b51.js} +28 -27
- package/dist/chunk-sb5qzv37.js +62 -0
- package/dist/{chunk-dx6yvj81.js → chunk-xhfbark7.js} +1 -1
- package/dist/chunk-z8e1ckkq.js +16 -0
- package/dist/connection/acquire.d.ts +3 -4
- package/dist/connection/acquire.js +2 -4
- package/dist/datasource.d.ts +0 -6
- package/dist/file/client.js +2 -2
- package/dist/file/index.js +4 -4
- package/dist/file/local.js +3 -3
- package/dist/file/pod.js +3 -3
- package/dist/http/client.d.ts +15 -0
- package/dist/http/client.js +21 -0
- package/dist/http/handler.d.ts +10 -0
- package/dist/http/handler.js +6 -0
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +21 -0
- package/dist/http/types.d.ts +34 -0
- package/dist/http/types.js +0 -0
- package/dist/kubernetes/client.js +9 -9
- package/dist/kubernetes/environment.js +9 -9
- package/dist/kubernetes/exec-transport.js +2 -2
- package/dist/kubernetes/pod-log-datasource.js +1 -1
- package/dist/kubernetes/port-forward.d.ts +4 -0
- package/dist/kubernetes/port-forward.js +1 -1
- package/dist/kubernetes/resource-transport.js +2 -2
- package/dist/kubernetes/service-port-forward.d.ts +3 -2
- package/dist/kubernetes/service-port-forward.js +2 -2
- package/dist/mysql/client.d.ts +7 -7
- package/dist/mysql/client.js +12 -7
- package/dist/mysql/index.js +16 -11
- package/dist/opensearch/client.d.ts +14 -4
- package/dist/opensearch/client.js +15 -8
- package/dist/opensearch/index.js +15 -8
- package/dist/redis/client.d.ts +7 -6
- package/dist/redis/client.js +12 -7
- package/dist/redis/index.js +16 -11
- package/dist/s3/client.d.ts +5 -3
- package/dist/s3/client.js +16 -2
- package/dist/s3/datasource.js +15 -8
- package/dist/s3/handler.d.ts +2 -3
- package/dist/s3/handler.js +2 -1
- package/dist/s3/index.js +15 -8
- package/dist/transport/index.d.ts +18 -8
- package/dist/transport/index.js +17 -4
- package/dist/transport/lifecycle.d.ts +22 -0
- package/dist/transport/lifecycle.js +8 -0
- package/dist/transport/pod-relay.d.ts +7 -4
- package/dist/transport/pod-relay.js +6 -3
- package/dist/transport/service-port-forward.d.ts +30 -0
- package/dist/transport/service-port-forward.js +15 -0
- package/package.json +9 -3
- package/dist/{chunk-x0yhfg29.js → chunk-5w85c7cs.js} +3 -3
- package/dist/{chunk-ww11q58v.js → chunk-rncagjbc.js} +3 -3
- package/dist/{chunk-r3rnsvwv.js → chunk-xeft7af0.js} +3 -3
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Client, ClientProvider, DataSource, EnvironmentContext and ClientManager are own
|
|
|
5
5
|
its lifecycle entrypoints re-export the same common symbols.
|
|
6
6
|
|
|
7
7
|
Shared infrastructure for diagnostic tools, test runners and business adapters. Use the same
|
|
8
|
-
clients to access Kubernetes, MySQL, Redis, OpenSearch and S3-compatible object storage without depending on a particular
|
|
8
|
+
clients to access HTTP(S), Kubernetes, MySQL, Redis, OpenSearch and S3-compatible object storage without depending on a particular
|
|
9
9
|
command, plugin protocol, test model or verdict.
|
|
10
10
|
|
|
11
11
|
The package supports Node.js 22+ and Bun. Install with `npm install @compforge/harness-toolbox`.
|
|
@@ -16,6 +16,31 @@ claiming snapshot consistency; callers supply cancellation and decide how to per
|
|
|
16
16
|
Protocol adapters have separate entry points; importing lifecycle or transport contracts does
|
|
17
17
|
not load database or Kubernetes drivers.
|
|
18
18
|
|
|
19
|
+
## HTTP requests
|
|
20
|
+
|
|
21
|
+
`HttpClient` borrows the same `Transport` as other protocol clients, keeping the logical Host and
|
|
22
|
+
TLS identity even when the socket goes through a port-forward. Limits include the response body;
|
|
23
|
+
statuses and redirects are returned without retries or redirect following.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { ClientManager } from "@compforge/harness-common";
|
|
27
|
+
import { HttpClient } from "@compforge/harness-toolbox/http";
|
|
28
|
+
import { DirectTransport } from "@compforge/harness-toolbox/transport";
|
|
29
|
+
|
|
30
|
+
const clients = new ClientManager();
|
|
31
|
+
try {
|
|
32
|
+
const route = await clients.get({ clientKey: "direct", createClient: () => new DirectTransport() });
|
|
33
|
+
const http = await clients.create({ createClient: (_clients, signal) => new HttpClient(
|
|
34
|
+
{ endpoint: "https://service.example" }, route,
|
|
35
|
+
{ concurrency: 4, connectTimeoutMs: 5000, requestTimeoutMs: 10000, maxResponseBytes: 1024 * 1024 },
|
|
36
|
+
{ signal },
|
|
37
|
+
) });
|
|
38
|
+
const response = await http.request("/health");
|
|
39
|
+
console.log(response.status, new TextDecoder().decode(response.body));
|
|
40
|
+
await http.dispose(); // The shared route remains available until root disposal.
|
|
41
|
+
} finally { await clients.dispose(); }
|
|
42
|
+
```
|
|
43
|
+
|
|
19
44
|
## File sources
|
|
20
45
|
|
|
21
46
|
A FileDataSource is a caller-declared acquisition group. Use `LocalFileDataSource` for local paths
|
|
@@ -69,7 +94,7 @@ select `single`, `cluster` or `sentinel`; `endpoints` contains that mode's seed
|
|
|
69
94
|
configuration additionally provides `sentinelMasterName` and optional Sentinel credentials,
|
|
70
95
|
separate from data-node credentials. Cluster connections require database zero.
|
|
71
96
|
|
|
72
|
-
Supply ordered TCP
|
|
97
|
+
Supply one `Transport`, with ordered TCP paths where needed. Only mapping or handshake network
|
|
73
98
|
failures advance to the next transport; authentication and TLS verification failures stop immediately.
|
|
74
99
|
`connectTimeoutMs` bounds each route (mapping and handshake); it defaults to the command
|
|
75
100
|
`timeoutMs`. `acquireTimeoutMs` bounds all candidates together and defaults to the per-route budget
|
|
@@ -194,8 +219,17 @@ The Makefile builds common before typechecking or building toolbox.
|
|
|
194
219
|
A `ClientProvider` identifies a reusable client and constructs it; `DataSource` adds data-access semantics.
|
|
195
220
|
Accessible environments implement ClientProvider directly without becoming data sources. The `Client` owns initialization
|
|
196
221
|
and idempotent disposal; a `ClientManager` joins concurrent initialization for the same key and
|
|
197
|
-
closes owned clients when the caller finishes.
|
|
198
|
-
and
|
|
222
|
+
closes owned clients when the caller finishes. Protocol clients take configuration (or an async resolver)
|
|
223
|
+
and one `Transport`; the transport selects and prepares access paths. `clients.create(factory)` creates
|
|
224
|
+
a fresh client that its caller may close early; transports acquired with `get` stay alive until root disposal.
|
|
225
|
+
|
|
226
|
+
For shared port-forwarding, register a `ServicePortForwardTransport` once per cluster/namespace, then pass the
|
|
227
|
+
borrowed transport to each protocol client. Closing an OpenSearch client leaves the forward available to the next
|
|
228
|
+
OpenSearch, MySQL, Redis or S3 client; root disposal closes the clients before stopping the forward processes.
|
|
229
|
+
Borrower cancellation only stops that borrower's wait. Exited forwards are recreated on the next acquisition.
|
|
230
|
+
Transport mapping changes the dial address, while protocol clients retain the original Host, TLS and signing identity.
|
|
231
|
+
In 0.5, replace `new Client({ resolve, transports }, options)` with
|
|
232
|
+
`new Client(resolve, transport, options)` (S3 retains its explicit limits argument).
|
|
199
233
|
|
|
200
234
|
```ts
|
|
201
235
|
import { ClientManager, clientKey, type DataSource } from "@compforge/harness-toolbox";
|
|
@@ -205,10 +239,8 @@ import { DirectTransport } from "@compforge/harness-toolbox/transport";
|
|
|
205
239
|
const target = { host: "localhost", port: 3306, database: "app", user: "reader", password: "..." };
|
|
206
240
|
const source: DataSource<MysqlClient> = {
|
|
207
241
|
clientKey: clientKey("mysql", target),
|
|
208
|
-
createClient: (_clients, signal) => new MysqlClient(
|
|
209
|
-
|
|
210
|
-
transports: [new DirectTransport()],
|
|
211
|
-
}, { signal, connectTimeoutMs: 5_000, queryTimeoutMs: 10_000 }),
|
|
242
|
+
createClient: (_clients, signal) => new MysqlClient(target, new DirectTransport(),
|
|
243
|
+
{ signal, connectTimeoutMs: 5_000, queryTimeoutMs: 10_000 }),
|
|
212
244
|
};
|
|
213
245
|
const clients = new ClientManager();
|
|
214
246
|
try {
|
|
@@ -351,7 +383,7 @@ try {
|
|
|
351
383
|
maxConnections: 16, maxTargets: 8, maxCandidatePods: 8, signal,
|
|
352
384
|
}),
|
|
353
385
|
});
|
|
354
|
-
//
|
|
386
|
+
// Pass this same transport instance to each protocol client; clients never dispose it.
|
|
355
387
|
const address = await relay.connect({host: 'database.internal', port: 3306});
|
|
356
388
|
// Connect the native protocol client to address; do not dispose relay in a Service.
|
|
357
389
|
} finally {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "./chunk-6fnhzhdb.js";
|
|
9
9
|
import {
|
|
10
10
|
PortForwardScope
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-mvkxs8d0.js";
|
|
12
12
|
|
|
13
13
|
// src/kubernetes/service-port-forward.ts
|
|
14
14
|
class ServicePortForwarder {
|
|
@@ -17,6 +17,8 @@ class ServicePortForwarder {
|
|
|
17
17
|
pods;
|
|
18
18
|
#cache = new Map;
|
|
19
19
|
#scope;
|
|
20
|
+
#handles = new Map;
|
|
21
|
+
#closed = false;
|
|
20
22
|
constructor(opts, services, pods, starter) {
|
|
21
23
|
this.opts = opts;
|
|
22
24
|
this.services = services;
|
|
@@ -31,14 +33,28 @@ class ServicePortForwarder {
|
|
|
31
33
|
return this.#scope.active;
|
|
32
34
|
}
|
|
33
35
|
forward(endpoint) {
|
|
36
|
+
if (this.#closed)
|
|
37
|
+
throw new Error("Port-forward transport is closed");
|
|
34
38
|
this.opts.signal?.throwIfAborted();
|
|
35
39
|
const key = `${endpoint.host}:${endpoint.port}`;
|
|
36
40
|
let pending = this.#cache.get(key);
|
|
37
41
|
if (!pending) {
|
|
38
42
|
pending = this.#forward(endpoint);
|
|
39
43
|
this.#cache.set(key, pending);
|
|
44
|
+
pending.catch(() => {
|
|
45
|
+
if (this.#cache.get(key) === pending)
|
|
46
|
+
this.#cache.delete(key);
|
|
47
|
+
});
|
|
40
48
|
}
|
|
41
|
-
return pending
|
|
49
|
+
return pending.then((mapped) => {
|
|
50
|
+
const handle = this.#handles.get(key);
|
|
51
|
+
if (handle?.isAlive && !handle.isAlive()) {
|
|
52
|
+
if (this.#cache.get(key) === pending)
|
|
53
|
+
this.#cache.delete(key);
|
|
54
|
+
return this.forward(endpoint);
|
|
55
|
+
}
|
|
56
|
+
return mapped;
|
|
57
|
+
});
|
|
42
58
|
}
|
|
43
59
|
async forwardServiceTargets(endpoint) {
|
|
44
60
|
const service = findService(this.services, endpoint, this.opts.namespace);
|
|
@@ -75,11 +91,26 @@ class ServicePortForwarder {
|
|
|
75
91
|
if (!result.value) {
|
|
76
92
|
throw new Error(`为 ${target.kind}/${target.name} 建立 port-forward 失败:${result.reason ?? "unknown error"}`);
|
|
77
93
|
}
|
|
94
|
+
if (this.#closed || this.opts.signal?.aborted) {
|
|
95
|
+
result.value.stop();
|
|
96
|
+
this.opts.signal?.throwIfAborted();
|
|
97
|
+
throw new Error("Port-forward transport is closed");
|
|
98
|
+
}
|
|
99
|
+
this.#handles.set(`${endpoint.host}:${endpoint.port}`, result.value);
|
|
78
100
|
return { host: "127.0.0.1", port: result.value.localPort, servername: endpoint.host };
|
|
79
101
|
}
|
|
80
102
|
stop() {
|
|
103
|
+
this.#closed = true;
|
|
81
104
|
this.#scope.stop();
|
|
82
105
|
this.#cache.clear();
|
|
106
|
+
this.#handles.clear();
|
|
107
|
+
}
|
|
108
|
+
async dispose() {
|
|
109
|
+
this.#closed = true;
|
|
110
|
+
await Promise.allSettled(this.#cache.values());
|
|
111
|
+
await this.#scope.dispose();
|
|
112
|
+
this.#cache.clear();
|
|
113
|
+
this.#handles.clear();
|
|
83
114
|
}
|
|
84
115
|
}
|
|
85
116
|
|
|
@@ -1,34 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Transport
|
|
3
|
+
} from "./chunk-sb5qzv37.js";
|
|
4
|
+
|
|
1
5
|
// src/transport/index.ts
|
|
2
|
-
class DirectTransport {
|
|
6
|
+
class DirectTransport extends Transport {
|
|
3
7
|
kind = "tcp";
|
|
4
8
|
name = "direct";
|
|
5
|
-
|
|
9
|
+
paths() {
|
|
10
|
+
return [this];
|
|
11
|
+
}
|
|
12
|
+
async connect(endpoint, attempt) {
|
|
13
|
+
this.signal.throwIfAborted();
|
|
14
|
+
attempt?.signal.throwIfAborted();
|
|
6
15
|
return endpoint;
|
|
7
16
|
}
|
|
8
17
|
}
|
|
9
18
|
|
|
10
|
-
class PortForwardTransport {
|
|
19
|
+
class PortForwardTransport extends Transport {
|
|
11
20
|
forward;
|
|
21
|
+
release;
|
|
12
22
|
kind = "tcp";
|
|
13
23
|
name = "port-forward";
|
|
14
|
-
constructor(forward) {
|
|
24
|
+
constructor(forward, release, signal) {
|
|
25
|
+
super([], signal);
|
|
15
26
|
this.forward = forward;
|
|
27
|
+
this.release = release;
|
|
28
|
+
}
|
|
29
|
+
paths() {
|
|
30
|
+
return [this];
|
|
16
31
|
}
|
|
17
|
-
connect(endpoint) {
|
|
18
|
-
|
|
32
|
+
connect(endpoint, attempt) {
|
|
33
|
+
this.signal.throwIfAborted();
|
|
34
|
+
attempt?.signal.throwIfAborted();
|
|
35
|
+
return this.forward(endpoint, attempt);
|
|
36
|
+
}
|
|
37
|
+
async close() {
|
|
38
|
+
await this.release?.();
|
|
19
39
|
}
|
|
20
40
|
}
|
|
21
41
|
|
|
22
|
-
class PodPythonTransport {
|
|
42
|
+
class PodPythonTransport extends Transport {
|
|
23
43
|
exec;
|
|
24
44
|
python;
|
|
25
45
|
kind = "python";
|
|
26
46
|
name = "pod-python";
|
|
27
47
|
constructor(exec, python = "python") {
|
|
48
|
+
super();
|
|
28
49
|
this.exec = exec;
|
|
29
50
|
this.python = python;
|
|
30
51
|
}
|
|
52
|
+
paths() {
|
|
53
|
+
return [this];
|
|
54
|
+
}
|
|
31
55
|
run(script, input, timeoutMs) {
|
|
56
|
+
this.signal.throwIfAborted();
|
|
32
57
|
return this.exec([this.python, "-c", script], { stdin: JSON.stringify(input), timeoutMs });
|
|
33
58
|
}
|
|
34
59
|
}
|
|
@@ -3,10 +3,13 @@ import {
|
|
|
3
3
|
} from "./chunk-z5pm2emz.js";
|
|
4
4
|
import {
|
|
5
5
|
S3Client
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-7m95c7pn.js";
|
|
7
7
|
import {
|
|
8
8
|
DirectTransport
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-18gcp0ck.js";
|
|
10
|
+
import {
|
|
11
|
+
Transport
|
|
12
|
+
} from "./chunk-sb5qzv37.js";
|
|
10
13
|
|
|
11
14
|
// src/s3/datasource.ts
|
|
12
15
|
class S3DataSource {
|
|
@@ -25,10 +28,7 @@ class S3DataSource {
|
|
|
25
28
|
});
|
|
26
29
|
}
|
|
27
30
|
createClient(_clients, signal) {
|
|
28
|
-
return new S3Client({
|
|
29
|
-
resolve: async () => this.#target,
|
|
30
|
-
transports: [this.#transport]
|
|
31
|
-
}, this.#limits, { signal });
|
|
31
|
+
return new S3Client(this.#target, this.#transport instanceof Transport ? this.#transport : new Transport([this.#transport]), this.#limits, { signal });
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -4,26 +4,7 @@ import {
|
|
|
4
4
|
} from "./chunk-rkt41za2.js";
|
|
5
5
|
|
|
6
6
|
// src/connection/acquire.ts
|
|
7
|
-
|
|
8
|
-
if (signal.aborted) {
|
|
9
|
-
cancel?.();
|
|
10
|
-
pending.catch(() => {});
|
|
11
|
-
signal.throwIfAborted();
|
|
12
|
-
}
|
|
13
|
-
let abort;
|
|
14
|
-
const cancelled = new Promise((_, reject) => {
|
|
15
|
-
abort = () => {
|
|
16
|
-
cancel?.();
|
|
17
|
-
reject(signal.reason);
|
|
18
|
-
};
|
|
19
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
20
|
-
});
|
|
21
|
-
try {
|
|
22
|
-
return await Promise.race([pending, cancelled]);
|
|
23
|
-
} finally {
|
|
24
|
-
signal.removeEventListener("abort", abort);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
7
|
+
import { raceSignal } from "race-signal";
|
|
27
8
|
async function acquireConnection(routes, options) {
|
|
28
9
|
const budget = options.acquireTimeoutMs ?? options.connectTimeoutMs * routes.length;
|
|
29
10
|
if (!routes.length)
|
|
@@ -75,9 +56,14 @@ async function acquireConnection(routes, options) {
|
|
|
75
56
|
}
|
|
76
57
|
return value;
|
|
77
58
|
});
|
|
78
|
-
|
|
59
|
+
options.onPending?.(prepared);
|
|
60
|
+
result = await raceSignal(prepared, attemptSignal);
|
|
79
61
|
} catch (failure) {
|
|
80
|
-
|
|
62
|
+
try {
|
|
63
|
+
await discard();
|
|
64
|
+
} catch (cleanup) {
|
|
65
|
+
throw new AggregateError([failure, cleanup], "Connection acquisition and cleanup failed", { cause: failure });
|
|
66
|
+
}
|
|
81
67
|
signal.throwIfAborted();
|
|
82
68
|
const error = attemptSignal.aborted ? attemptSignal.reason : failure;
|
|
83
69
|
reason = options.retryReason(error);
|
|
@@ -93,7 +79,11 @@ async function acquireConnection(routes, options) {
|
|
|
93
79
|
attemptSignal.throwIfAborted();
|
|
94
80
|
return result.connection;
|
|
95
81
|
} catch (error) {
|
|
96
|
-
|
|
82
|
+
try {
|
|
83
|
+
await discard();
|
|
84
|
+
} catch (cleanup) {
|
|
85
|
+
throw new AggregateError([error, cleanup], "Connection publication and cleanup failed", { cause: error });
|
|
86
|
+
}
|
|
97
87
|
throw error;
|
|
98
88
|
}
|
|
99
89
|
}
|
|
@@ -103,4 +93,4 @@ async function acquireConnection(routes, options) {
|
|
|
103
93
|
}
|
|
104
94
|
}
|
|
105
95
|
|
|
106
|
-
export {
|
|
96
|
+
export { acquireConnection };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isConnectionNetworkError
|
|
3
|
+
} from "./chunk-18gcp0ck.js";
|
|
4
|
+
import {
|
|
5
|
+
ConcurrencyPool
|
|
6
|
+
} from "./chunk-v3hxvnrc.js";
|
|
7
|
+
import {
|
|
8
|
+
httpHandler
|
|
9
|
+
} from "./chunk-c2b2jwm4.js";
|
|
10
|
+
import {
|
|
11
|
+
TimeoutError,
|
|
12
|
+
ToolboxError
|
|
13
|
+
} from "./chunk-rkt41za2.js";
|
|
14
|
+
|
|
15
|
+
// src/http/client.ts
|
|
16
|
+
import { HttpRequest } from "@smithy/core/protocols";
|
|
17
|
+
import { Readable } from "node:stream";
|
|
18
|
+
import { raceSignal } from "race-signal";
|
|
19
|
+
class HttpClient {
|
|
20
|
+
transport;
|
|
21
|
+
lifecycle;
|
|
22
|
+
#controller = new AbortController;
|
|
23
|
+
signal;
|
|
24
|
+
#endpoint;
|
|
25
|
+
#target;
|
|
26
|
+
#limits;
|
|
27
|
+
#pool;
|
|
28
|
+
#operations = new Set;
|
|
29
|
+
#initialization;
|
|
30
|
+
#disposal;
|
|
31
|
+
#handler;
|
|
32
|
+
constructor(target, transport, limits, lifecycle = {}) {
|
|
33
|
+
this.transport = transport;
|
|
34
|
+
this.lifecycle = lifecycle;
|
|
35
|
+
for (const name of ["concurrency", "connectTimeoutMs", "requestTimeoutMs", "maxResponseBytes"]) {
|
|
36
|
+
const value = limits[name];
|
|
37
|
+
if (!Number.isSafeInteger(value) || value < 1 || name.endsWith("Ms") && value > 2147483647) {
|
|
38
|
+
throw new Error(`${name} must be a positive integer within its supported range`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.#endpoint = new URL(target.endpoint);
|
|
42
|
+
if (!["http:", "https:"].includes(this.#endpoint.protocol) || this.#endpoint.username || this.#endpoint.password || this.#endpoint.search || this.#endpoint.hash)
|
|
43
|
+
throw new Error("HTTP endpoint requires HTTP(S), without credentials, query or fragment");
|
|
44
|
+
this.#target = { ...target };
|
|
45
|
+
this.#limits = { ...limits };
|
|
46
|
+
this.#pool = new ConcurrencyPool(limits.concurrency);
|
|
47
|
+
this.signal = AbortSignal.any([this.#controller.signal, transport.signal, ...lifecycle.signal ? [lifecycle.signal] : []]);
|
|
48
|
+
}
|
|
49
|
+
initialize() {
|
|
50
|
+
this.signal.throwIfAborted();
|
|
51
|
+
return this.#initialization ??= (async () => {
|
|
52
|
+
const endpoint = this.#endpoint;
|
|
53
|
+
const route = await this.transport.acquire({
|
|
54
|
+
name: "HTTP route",
|
|
55
|
+
signal: this.signal,
|
|
56
|
+
connectTimeoutMs: this.#limits.connectTimeoutMs,
|
|
57
|
+
onRoute: this.lifecycle.onRoute,
|
|
58
|
+
open: async (path, attempt) => {
|
|
59
|
+
if (path.kind !== "tcp")
|
|
60
|
+
throw new Error("HTTP requires a TCP transport");
|
|
61
|
+
return { connection: await path.connect({
|
|
62
|
+
host: endpoint.hostname.replace(/^\[|\]$/g, ""),
|
|
63
|
+
port: Number(endpoint.port || (endpoint.protocol === "https:" ? 443 : 80))
|
|
64
|
+
}, attempt), transport: path.name };
|
|
65
|
+
},
|
|
66
|
+
close: () => {},
|
|
67
|
+
retryReason: (error) => isConnectionNetworkError(error) ? String(error.code) : undefined
|
|
68
|
+
});
|
|
69
|
+
this.signal.throwIfAborted();
|
|
70
|
+
this.#handler = httpHandler(this.#target, route, this.#limits);
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
request(path, options = {}) {
|
|
74
|
+
this.signal.throwIfAborted();
|
|
75
|
+
const url = new URL(path, this.#endpoint);
|
|
76
|
+
if (url.origin !== this.#endpoint.origin || url.username || url.password || url.hash) {
|
|
77
|
+
throw new Error("HTTP request must retain the configured origin and contain no credentials or fragment");
|
|
78
|
+
}
|
|
79
|
+
const headers = {};
|
|
80
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
81
|
+
if (name.toLowerCase() === "host")
|
|
82
|
+
throw new Error("HTTP Host is owned by the configured endpoint");
|
|
83
|
+
headers[name.toLowerCase()] = value;
|
|
84
|
+
}
|
|
85
|
+
headers.host = url.host;
|
|
86
|
+
const timeout = new AbortController;
|
|
87
|
+
const timer = setTimeout(() => timeout.abort(new TimeoutError(`HTTP request timed out after ${this.#limits.requestTimeoutMs}ms`)), this.#limits.requestTimeoutMs);
|
|
88
|
+
timer.unref();
|
|
89
|
+
const signal = AbortSignal.any([this.signal, timeout.signal, ...options.signal ? [options.signal] : []]);
|
|
90
|
+
const pending = this.#pool.run(async () => {
|
|
91
|
+
await raceSignal(this.initialize(), signal);
|
|
92
|
+
signal.throwIfAborted();
|
|
93
|
+
const { response } = await this.#handler.handle(new HttpRequest({
|
|
94
|
+
protocol: url.protocol,
|
|
95
|
+
hostname: url.hostname,
|
|
96
|
+
port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
|
|
97
|
+
method: options.method ?? "GET",
|
|
98
|
+
path: url.pathname + url.search,
|
|
99
|
+
headers,
|
|
100
|
+
body: options.body
|
|
101
|
+
}), { abortSignal: signal });
|
|
102
|
+
const body = response.body;
|
|
103
|
+
if (!(body instanceof Readable))
|
|
104
|
+
throw new ToolboxError("HTTP response has no readable body", { kind: "invalid_response" });
|
|
105
|
+
const abort = () => body.destroy(new Error("HTTP body read cancelled", { cause: signal.reason }));
|
|
106
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
107
|
+
try {
|
|
108
|
+
signal.throwIfAborted();
|
|
109
|
+
const chunks = [];
|
|
110
|
+
let size = 0;
|
|
111
|
+
for await (const chunk of body) {
|
|
112
|
+
signal.throwIfAborted();
|
|
113
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
114
|
+
size += bytes.length;
|
|
115
|
+
if (size > this.#limits.maxResponseBytes)
|
|
116
|
+
throw new ToolboxError(`HTTP response exceeded ${this.#limits.maxResponseBytes} bytes`, { kind: "limit_exceeded" });
|
|
117
|
+
chunks.push(bytes);
|
|
118
|
+
}
|
|
119
|
+
return { status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks, size) };
|
|
120
|
+
} finally {
|
|
121
|
+
signal.removeEventListener("abort", abort);
|
|
122
|
+
body.destroy();
|
|
123
|
+
}
|
|
124
|
+
}, signal).catch((error) => {
|
|
125
|
+
signal.throwIfAborted();
|
|
126
|
+
throw error;
|
|
127
|
+
}).finally(() => clearTimeout(timer));
|
|
128
|
+
this.#operations.add(pending);
|
|
129
|
+
pending.then(() => this.#operations.delete(pending), () => this.#operations.delete(pending));
|
|
130
|
+
return pending;
|
|
131
|
+
}
|
|
132
|
+
dispose() {
|
|
133
|
+
return this.#disposal ??= (async () => {
|
|
134
|
+
this.#controller.abort(new Error("HTTP client disposed"));
|
|
135
|
+
await this.#initialization?.catch(() => {});
|
|
136
|
+
await Promise.allSettled(this.#operations);
|
|
137
|
+
this.#handler?.destroy();
|
|
138
|
+
})();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { HttpClient };
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isConnectionNetworkError
|
|
3
|
+
} from "./chunk-18gcp0ck.js";
|
|
1
4
|
import {
|
|
2
5
|
ConcurrencyPool
|
|
3
6
|
} from "./chunk-v3hxvnrc.js";
|
|
4
7
|
import {
|
|
5
8
|
s3Handler
|
|
6
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-z8e1ckkq.js";
|
|
7
10
|
|
|
8
11
|
// src/s3/client.ts
|
|
9
12
|
import {
|
|
@@ -33,7 +36,8 @@ function metadata(value) {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
class S3Client {
|
|
36
|
-
|
|
39
|
+
configuration;
|
|
40
|
+
transport;
|
|
37
41
|
limits;
|
|
38
42
|
lifecycle;
|
|
39
43
|
#controller = new AbortController;
|
|
@@ -44,8 +48,9 @@ class S3Client {
|
|
|
44
48
|
#disposal;
|
|
45
49
|
#sdk;
|
|
46
50
|
#http;
|
|
47
|
-
constructor(
|
|
48
|
-
this.
|
|
51
|
+
constructor(configuration, transport, limits, lifecycle = {}) {
|
|
52
|
+
this.configuration = configuration;
|
|
53
|
+
this.transport = transport;
|
|
49
54
|
this.limits = limits;
|
|
50
55
|
this.lifecycle = lifecycle;
|
|
51
56
|
positive(limits.concurrency, "concurrency");
|
|
@@ -59,19 +64,28 @@ class S3Client {
|
|
|
59
64
|
return this.#initialization ??= this.#initialize();
|
|
60
65
|
}
|
|
61
66
|
async#initialize() {
|
|
62
|
-
const target = await this.
|
|
67
|
+
const target = await (typeof this.configuration === "function" ? this.configuration() : Promise.resolve(this.configuration));
|
|
63
68
|
this.signal.throwIfAborted();
|
|
64
69
|
const url = new URL(target.endpoint);
|
|
65
70
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
66
71
|
throw new Error("S3 endpoint must be an HTTP(S) URL without embedded credentials, query or fragment");
|
|
67
72
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const route = await this.transport.acquire({
|
|
74
|
+
name: "S3 route",
|
|
75
|
+
signal: this.signal,
|
|
76
|
+
connectTimeoutMs: this.limits.connectTimeoutMs,
|
|
77
|
+
onRoute: this.lifecycle.onRoute,
|
|
78
|
+
open: async (path, attempt) => {
|
|
79
|
+
if (path.kind !== "tcp")
|
|
80
|
+
throw new Error("S3 requires a TCP transport");
|
|
81
|
+
const connection = await path.connect({
|
|
82
|
+
host: url.hostname,
|
|
83
|
+
port: Number(url.port || (url.protocol === "https:" ? 443 : 80))
|
|
84
|
+
}, attempt);
|
|
85
|
+
return { connection, transport: path.name };
|
|
86
|
+
},
|
|
87
|
+
close: () => {},
|
|
88
|
+
retryReason: (error) => isConnectionNetworkError(error) ? String(error.code) : undefined
|
|
75
89
|
});
|
|
76
90
|
this.signal.throwIfAborted();
|
|
77
91
|
this.#http = s3Handler(target, route, this.limits);
|
|
@@ -85,7 +99,6 @@ class S3Client {
|
|
|
85
99
|
followRegionRedirects: false,
|
|
86
100
|
responseChecksumValidation: "WHEN_REQUIRED"
|
|
87
101
|
});
|
|
88
|
-
this.lifecycle.onRoute?.({ transport: transport.name });
|
|
89
102
|
}
|
|
90
103
|
#run(options, operation) {
|
|
91
104
|
this.signal.throwIfAborted();
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/http/handler.ts
|
|
2
2
|
import { Agent as HttpAgent } from "node:http";
|
|
3
3
|
import { Agent as HttpsAgent } from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { checkServerIdentity } from "node:tls";
|
|
4
6
|
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
5
|
-
function
|
|
7
|
+
function httpHandler(target, route, limits) {
|
|
6
8
|
const endpoint = new URL(target.endpoint);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
throw new Error("S3 mapped transports require path-style addressing to preserve bucket and TLS identity");
|
|
10
|
-
}
|
|
9
|
+
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "");
|
|
10
|
+
const mapped = route.host !== hostname || route.port !== Number(endpoint.port || (endpoint.protocol === "https:" ? 443 : 80));
|
|
11
11
|
const pool = { keepAlive: true, maxSockets: limits.concurrency, maxTotalSockets: limits.concurrency };
|
|
12
12
|
const httpAgent = new HttpAgent(pool);
|
|
13
13
|
const httpsAgent = new HttpsAgent({
|
|
14
14
|
...pool,
|
|
15
15
|
ca: target.ca,
|
|
16
16
|
rejectUnauthorized: true,
|
|
17
|
-
...mapped ? {
|
|
17
|
+
...mapped ? {
|
|
18
|
+
servername: isIP(hostname) ? "" : hostname,
|
|
19
|
+
checkServerIdentity: (_host, certificate) => checkServerIdentity(hostname, certificate)
|
|
20
|
+
} : {}
|
|
18
21
|
});
|
|
19
|
-
const
|
|
22
|
+
const engine = new NodeHttpHandler({
|
|
20
23
|
httpAgent,
|
|
21
24
|
httpsAgent,
|
|
22
25
|
connectionTimeout: limits.connectTimeoutMs,
|
|
23
26
|
requestTimeout: limits.requestTimeoutMs,
|
|
24
27
|
throwOnRequestTimeout: true
|
|
25
28
|
});
|
|
26
|
-
|
|
29
|
+
return {
|
|
27
30
|
handle(request, options) {
|
|
28
31
|
if (!mapped)
|
|
29
|
-
return
|
|
32
|
+
return engine.handle(request, options);
|
|
30
33
|
const routed = request.clone();
|
|
31
34
|
routed.hostname = route.host;
|
|
32
35
|
routed.port = route.port;
|
|
33
|
-
return
|
|
36
|
+
return engine.handle(routed, options);
|
|
34
37
|
},
|
|
35
38
|
destroy() {
|
|
36
|
-
|
|
39
|
+
engine.destroy();
|
|
40
|
+
httpAgent.destroy();
|
|
41
|
+
httpsAgent.destroy();
|
|
37
42
|
}
|
|
38
43
|
};
|
|
39
|
-
return { handler, destroy: () => {
|
|
40
|
-
handler.destroy();
|
|
41
|
-
httpAgent.destroy();
|
|
42
|
-
httpsAgent.destroy();
|
|
43
|
-
} };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export {
|
|
46
|
+
export { httpHandler };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ServicePortForwarder
|
|
3
|
+
} from "./chunk-0d8vs5q8.js";
|
|
4
|
+
import {
|
|
5
|
+
KubectlExecutor
|
|
6
|
+
} from "./chunk-rq5fc007.js";
|
|
7
|
+
import {
|
|
8
|
+
Transport
|
|
9
|
+
} from "./chunk-sb5qzv37.js";
|
|
10
|
+
|
|
11
|
+
// src/transport/service-port-forward.ts
|
|
12
|
+
import { raceSignal } from "race-signal";
|
|
13
|
+
class ServicePortForwardTransport extends Transport {
|
|
14
|
+
options;
|
|
15
|
+
dependencies;
|
|
16
|
+
kind = "tcp";
|
|
17
|
+
name = "port-forward";
|
|
18
|
+
#initialization;
|
|
19
|
+
#forwarder;
|
|
20
|
+
constructor(options, dependencies) {
|
|
21
|
+
super([], options.signal);
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.dependencies = dependencies;
|
|
24
|
+
}
|
|
25
|
+
paths() {
|
|
26
|
+
return [this];
|
|
27
|
+
}
|
|
28
|
+
initialize() {
|
|
29
|
+
this.signal.throwIfAborted();
|
|
30
|
+
const pending = this.#initialization ??= (async () => {
|
|
31
|
+
this.#forwarder = await ServicePortForwarder.create(this.dependencies?.executor ?? new KubectlExecutor(this.options), { ...this.options, signal: this.resourceSignal }, this.dependencies?.startForward);
|
|
32
|
+
this.signal.throwIfAborted();
|
|
33
|
+
})();
|
|
34
|
+
return raceSignal(pending, this.signal);
|
|
35
|
+
}
|
|
36
|
+
get activeForwards() {
|
|
37
|
+
return this.#forwarder?.activeForwards ?? [];
|
|
38
|
+
}
|
|
39
|
+
async connect(endpoint, attempt) {
|
|
40
|
+
this.signal.throwIfAborted();
|
|
41
|
+
const signal = attempt ? AbortSignal.any([this.signal, attempt.signal]) : this.signal;
|
|
42
|
+
await raceSignal(this.initialize(), signal);
|
|
43
|
+
const mapped = await raceSignal(this.#forwarder.forward(endpoint), signal);
|
|
44
|
+
return { host: mapped.host, port: mapped.port };
|
|
45
|
+
}
|
|
46
|
+
async close() {
|
|
47
|
+
await this.#initialization?.catch(() => {});
|
|
48
|
+
await this.#forwarder?.dispose();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { ServicePortForwardTransport };
|