@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.
Files changed (70) hide show
  1. package/README.md +41 -9
  2. package/dist/{chunk-1ts7pswj.js → chunk-0d8vs5q8.js} +33 -2
  3. package/dist/{chunk-h8fwx0k7.js → chunk-18gcp0ck.js} +32 -7
  4. package/dist/{chunk-35cjec9z.js → chunk-3b877a0y.js} +6 -6
  5. package/dist/{chunk-gm4swndx.js → chunk-43mcyrh4.js} +14 -24
  6. package/dist/chunk-56fev9np.js +142 -0
  7. package/dist/{chunk-zp1s833x.js → chunk-7m95c7pn.js} +26 -13
  8. package/dist/{chunk-f3vq900j.js → chunk-92aacbwh.js} +1 -1
  9. package/dist/{chunk-x4ntb898.js → chunk-c2b2jwm4.js} +18 -18
  10. package/dist/chunk-ehmx0yhp.js +52 -0
  11. package/dist/{chunk-t4hrnaj0.js → chunk-ex429qjt.js} +22 -20
  12. package/dist/{chunk-m391jmpw.js → chunk-hbhbragw.js} +9 -9
  13. package/dist/{chunk-fqrknvr8.js → chunk-kap6ka3n.js} +27 -12
  14. package/dist/{chunk-vymm4g9h.js → chunk-kxpecgy7.js} +106 -37
  15. package/dist/{chunk-nftzckdy.js → chunk-mvkxs8d0.js} +17 -2
  16. package/dist/{chunk-f98qrpx1.js → chunk-njs48b51.js} +28 -27
  17. package/dist/chunk-sb5qzv37.js +62 -0
  18. package/dist/{chunk-dx6yvj81.js → chunk-xhfbark7.js} +1 -1
  19. package/dist/chunk-z8e1ckkq.js +16 -0
  20. package/dist/connection/acquire.d.ts +3 -4
  21. package/dist/connection/acquire.js +2 -4
  22. package/dist/datasource.d.ts +0 -6
  23. package/dist/file/client.js +2 -2
  24. package/dist/file/index.js +4 -4
  25. package/dist/file/local.js +3 -3
  26. package/dist/file/pod.js +3 -3
  27. package/dist/http/client.d.ts +15 -0
  28. package/dist/http/client.js +21 -0
  29. package/dist/http/handler.d.ts +10 -0
  30. package/dist/http/handler.js +6 -0
  31. package/dist/http/index.d.ts +2 -0
  32. package/dist/http/index.js +21 -0
  33. package/dist/http/types.d.ts +34 -0
  34. package/dist/http/types.js +0 -0
  35. package/dist/kubernetes/client.js +9 -9
  36. package/dist/kubernetes/environment.js +9 -9
  37. package/dist/kubernetes/exec-transport.js +2 -2
  38. package/dist/kubernetes/pod-log-datasource.js +1 -1
  39. package/dist/kubernetes/port-forward.d.ts +4 -0
  40. package/dist/kubernetes/port-forward.js +1 -1
  41. package/dist/kubernetes/resource-transport.js +2 -2
  42. package/dist/kubernetes/service-port-forward.d.ts +3 -2
  43. package/dist/kubernetes/service-port-forward.js +2 -2
  44. package/dist/mysql/client.d.ts +7 -7
  45. package/dist/mysql/client.js +12 -7
  46. package/dist/mysql/index.js +16 -11
  47. package/dist/opensearch/client.d.ts +14 -4
  48. package/dist/opensearch/client.js +15 -8
  49. package/dist/opensearch/index.js +15 -8
  50. package/dist/redis/client.d.ts +7 -6
  51. package/dist/redis/client.js +12 -7
  52. package/dist/redis/index.js +16 -11
  53. package/dist/s3/client.d.ts +5 -3
  54. package/dist/s3/client.js +16 -2
  55. package/dist/s3/datasource.js +15 -8
  56. package/dist/s3/handler.d.ts +2 -3
  57. package/dist/s3/handler.js +2 -1
  58. package/dist/s3/index.js +15 -8
  59. package/dist/transport/index.d.ts +18 -8
  60. package/dist/transport/index.js +17 -4
  61. package/dist/transport/lifecycle.d.ts +22 -0
  62. package/dist/transport/lifecycle.js +8 -0
  63. package/dist/transport/pod-relay.d.ts +7 -4
  64. package/dist/transport/pod-relay.js +6 -3
  65. package/dist/transport/service-port-forward.d.ts +30 -0
  66. package/dist/transport/service-port-forward.js +15 -0
  67. package/package.json +9 -3
  68. package/dist/{chunk-x0yhfg29.js → chunk-5w85c7cs.js} +3 -3
  69. package/dist/{chunk-ww11q58v.js → chunk-rncagjbc.js} +3 -3
  70. 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 transports through `ConnectionSource`. Only mapping or handshake network
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. A `ConnectionSource` resolves connection settings
198
- and available `Transport` paths. Clients execute protocol operations through those paths.
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
- resolve: async () => target,
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
- // The same instance belongs in each protocol client's ConnectionSource.transports.
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-nftzckdy.js";
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
- async connect(endpoint) {
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
- return this.forward(endpoint);
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-zp1s833x.js";
6
+ } from "./chunk-7m95c7pn.js";
7
7
  import {
8
8
  DirectTransport
9
- } from "./chunk-h8fwx0k7.js";
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
- async function awaitWithSignal(pending, signal, cancel) {
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
- result = await awaitWithSignal(prepared, attemptSignal);
59
+ options.onPending?.(prepared);
60
+ result = await raceSignal(prepared, attemptSignal);
79
61
  } catch (failure) {
80
- await discard();
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
- await discard();
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 { awaitWithSignal, acquireConnection };
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-x4ntb898.js";
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
- source;
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(source, limits, lifecycle = {}) {
48
- this.source = source;
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.source.resolve();
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 transport = this.source.transports[0];
69
- if (!transport || transport.kind !== "tcp")
70
- throw new Error("S3 requires a TCP transport");
71
- const route = await transport.connect({
72
- host: url.hostname,
73
- port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
74
- servername: url.hostname
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();
@@ -2,7 +2,7 @@ import {
2
2
  BoundedFileClient,
3
3
  fileClientOptions,
4
4
  filePaths
5
- } from "./chunk-ww11q58v.js";
5
+ } from "./chunk-rncagjbc.js";
6
6
  import {
7
7
  FileError
8
8
  } from "./chunk-rkt41za2.js";
@@ -1,46 +1,46 @@
1
- // src/s3/handler.ts
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 s3Handler(target, route, limits) {
7
+ function httpHandler(target, route, limits) {
6
8
  const endpoint = new URL(target.endpoint);
7
- const mapped = route.host !== endpoint.hostname || route.port !== Number(endpoint.port || (endpoint.protocol === "https:" ? 443 : 80));
8
- if (mapped && target.forcePathStyle === false) {
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 ? { servername: endpoint.hostname } : {}
17
+ ...mapped ? {
18
+ servername: isIP(hostname) ? "" : hostname,
19
+ checkServerIdentity: (_host, certificate) => checkServerIdentity(hostname, certificate)
20
+ } : {}
18
21
  });
19
- const transport = new NodeHttpHandler({
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
- const handler = {
29
+ return {
27
30
  handle(request, options) {
28
31
  if (!mapped)
29
- return transport.handle(request, options);
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 transport.handle(routed, options);
36
+ return engine.handle(routed, options);
34
37
  },
35
38
  destroy() {
36
- transport.destroy();
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 { s3Handler };
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 };