@zlink-systems/http-client 0.10.0

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.
@@ -0,0 +1,14 @@
1
+ import type { HttpClientOptions } from './options';
2
+ import type { HttpRequestSpec, RawResult } from './request-performer';
3
+ /**
4
+ * Applies the wrapper's retry policy around a single request attempt: streaming requests are never
5
+ * retried (they cannot be rewound), each attempt is bounded by the effective timeout via an
6
+ * `AbortController`, and only retriable transport failures (timeout, connection errors) are retried
7
+ * at a fixed delay. Separated from the runtime so the retry/timeout policy is independent of
8
+ * dispatcher construction.
9
+ */
10
+ export declare class RetryPolicy {
11
+ private readonly options;
12
+ constructor(options: HttpClientOptions);
13
+ execute(spec: HttpRequestSpec, perform: (spec: HttpRequestSpec, signal: AbortSignal) => Promise<RawResult>): Promise<RawResult>;
14
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.RetryPolicy = void 0;
5
+ const framework_1 = require("@zlink-systems/framework");
6
+ // Exponential backoff with full jitter: base 50ms, doubling per attempt, capped at 1s.
7
+ // Fixed delays synchronize retries from many clients against an ailing server.
8
+ function delayMsFor(attempt) {
9
+ const ceiling = Math.min(1000, 50 << Math.min(attempt, 5));
10
+ return Math.floor(Math.random() * (ceiling + 1));
11
+ }
12
+ /**
13
+ * Applies the wrapper's retry policy around a single request attempt: streaming requests are never
14
+ * retried (they cannot be rewound), each attempt is bounded by the effective timeout via an
15
+ * `AbortController`, and only retriable transport failures (timeout, connection errors) are retried
16
+ * at a fixed delay. Separated from the runtime so the retry/timeout policy is independent of
17
+ * dispatcher construction.
18
+ */
19
+ class RetryPolicy {
20
+ options;
21
+ constructor(options) {
22
+ this.options = options;
23
+ }
24
+ async execute(spec, perform) {
25
+ const maxRetries = spec.sink !== undefined || spec.bodyProvider !== undefined ? 0 : this.options.retryAttempts;
26
+ const timeoutMs = spec.timeoutMs ?? this.options.timeoutMs;
27
+ for (let attempt = 0;; attempt++) {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => {
30
+ controller.abort();
31
+ }, timeoutMs);
32
+ try {
33
+ return await perform(spec, controller.signal);
34
+ }
35
+ catch (error) {
36
+ const failure = mapFailure(error, controller.signal.aborted);
37
+ if (isRetriableHttpFailure(failure) && attempt < maxRetries) {
38
+ await delay(delayMsFor(attempt));
39
+ continue;
40
+ }
41
+ throw failure;
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+ }
48
+ }
49
+ exports.RetryPolicy = RetryPolicy;
50
+ function mapFailure(error, aborted) {
51
+ if (error instanceof framework_1.ZLinkFrameworkException) {
52
+ return error;
53
+ }
54
+ if (aborted || (error instanceof Error && error.name === 'AbortError')) {
55
+ return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.DeadlineExceeded, 'HTTP request exceeded timeout', error);
56
+ }
57
+ // Transport failures (connection refused/reset, undici errors) are retriable.
58
+ const message = error instanceof Error ? error.message : 'HTTP transport failure';
59
+ return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, message, error);
60
+ }
61
+ function isRetriableHttpFailure(error) {
62
+ return error.kind === framework_1.ZLinkFrameworkErrorKind.Unavailable
63
+ || error.kind === framework_1.ZLinkFrameworkErrorKind.DeadlineExceeded;
64
+ }
65
+ function delay(ms) {
66
+ return new Promise((resolve) => {
67
+ setTimeout(resolve, ms);
68
+ });
69
+ }
@@ -0,0 +1,16 @@
1
+ import type { HttpClientOptions } from './options';
2
+ import { type HttpRequestSpec, type RawResult } from './request-performer';
3
+ /**
4
+ * Wires the undici dispatcher, request performer, and retry policy. Dispatcher construction lives in
5
+ * `transport-factory` and the retry/timeout loop in {@link RetryPolicy}. Submission is the caller's
6
+ * native `Promise`; the event loop is never blocked. Mirrors the C++ `http_client_runtime.cpp`.
7
+ */
8
+ export declare class HttpClientRuntime {
9
+ private readonly cookieJar;
10
+ private readonly dispatcher;
11
+ private readonly performer;
12
+ private readonly retryPolicy;
13
+ constructor(options: HttpClientOptions);
14
+ executeAsync(spec: HttpRequestSpec): Promise<RawResult>;
15
+ close(): Promise<void>;
16
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.HttpClientRuntime = void 0;
5
+ const cookie_jar_1 = require("./cookie-jar");
6
+ const request_performer_1 = require("./request-performer");
7
+ const transport_factory_1 = require("./transport-factory");
8
+ const retry_policy_1 = require("./retry-policy");
9
+ /**
10
+ * Wires the undici dispatcher, request performer, and retry policy. Dispatcher construction lives in
11
+ * `transport-factory` and the retry/timeout loop in {@link RetryPolicy}. Submission is the caller's
12
+ * native `Promise`; the event loop is never blocked. Mirrors the C++ `http_client_runtime.cpp`.
13
+ */
14
+ class HttpClientRuntime {
15
+ cookieJar = new cookie_jar_1.CookieJar();
16
+ dispatcher;
17
+ performer;
18
+ retryPolicy;
19
+ constructor(options) {
20
+ this.dispatcher = (0, transport_factory_1.createDispatcher)(options);
21
+ this.performer = new request_performer_1.RequestPerformer(options, this.cookieJar, this.dispatcher);
22
+ this.retryPolicy = new retry_policy_1.RetryPolicy(options);
23
+ }
24
+ async executeAsync(spec) {
25
+ return this.retryPolicy.execute(spec, (request, signal) => this.performer.perform(request, signal));
26
+ }
27
+ async close() {
28
+ if (this.dispatcher !== undefined) {
29
+ await this.dispatcher.close();
30
+ }
31
+ }
32
+ }
33
+ exports.HttpClientRuntime = HttpClientRuntime;
@@ -0,0 +1,5 @@
1
+ export declare function requireNonBlank(value: string, message: string): void;
2
+ export declare function requirePositiveTimeout(value: number): void;
3
+ export declare function percentEncode(value: string): string;
4
+ export declare function basicAuthorization(user: string, password: string): string;
5
+ export declare function makeMultipartBoundary(): string;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.requireNonBlank = requireNonBlank;
5
+ exports.requirePositiveTimeout = requirePositiveTimeout;
6
+ exports.percentEncode = percentEncode;
7
+ exports.basicAuthorization = basicAuthorization;
8
+ exports.makeMultipartBoundary = makeMultipartBoundary;
9
+ const framework_1 = require("@zlink-systems/framework");
10
+ /** Shared text helpers mirroring the C++ `client.cpp` anonymous-namespace utilities. */
11
+ function isBlank(value) {
12
+ return value.length === 0 || /^[\s]*$/u.test(value);
13
+ }
14
+ function requireNonBlank(value, message) {
15
+ if (isBlank(value)) {
16
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.ProtocolError, message);
17
+ }
18
+ }
19
+ function requirePositiveTimeout(value) {
20
+ if (!(value > 0)) {
21
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.ProtocolError, 'HTTP client timeout must be greater than zero');
22
+ }
23
+ }
24
+ const unreserved = /[A-Za-z0-9\-_.~]/u;
25
+ function percentEncode(value) {
26
+ const bytes = new TextEncoder().encode(value);
27
+ let encoded = '';
28
+ for (const byte of bytes) {
29
+ const char = String.fromCharCode(byte);
30
+ if (unreserved.test(char)) {
31
+ encoded += char;
32
+ }
33
+ else {
34
+ encoded += '%' + byte.toString(16).toUpperCase().padStart(2, '0');
35
+ }
36
+ }
37
+ return encoded;
38
+ }
39
+ function basicAuthorization(user, password) {
40
+ return 'Basic ' + Buffer.from(`${user}:${password}`, 'utf8').toString('base64');
41
+ }
42
+ function makeMultipartBoundary() {
43
+ return 'zlink-boundary-' + Math.random().toString(16).slice(2).padEnd(16, '0').slice(0, 16);
44
+ }
@@ -0,0 +1,9 @@
1
+ import { type Dispatcher } from 'undici';
2
+ import type { HttpClientOptions } from './options';
3
+ /**
4
+ * Builds the undici dispatcher for the wrapper: a `ProxyAgent` when a proxy is configured (proxy
5
+ * authentication carried as a token), otherwise a TLS `Agent` (trust certificate + mTLS client
6
+ * certificate) or the global dispatcher when no transport customisation is needed. Separated from
7
+ * the runtime so dispatcher construction is independent of request orchestration.
8
+ */
9
+ export declare function createDispatcher(options: HttpClientOptions): Dispatcher | undefined;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.createDispatcher = createDispatcher;
5
+ const node_fs_1 = require("node:fs");
6
+ const node_tls_1 = require("node:tls");
7
+ const undici_1 = require("undici");
8
+ /**
9
+ * Builds the undici dispatcher for the wrapper: a `ProxyAgent` when a proxy is configured (proxy
10
+ * authentication carried as a token), otherwise a TLS `Agent` (trust certificate + mTLS client
11
+ * certificate) or the global dispatcher when no transport customisation is needed. Separated from
12
+ * the runtime so dispatcher construction is independent of request orchestration.
13
+ */
14
+ function createDispatcher(options) {
15
+ const connect = {};
16
+ let hasTls = false;
17
+ if (options.trustCertificateFile !== undefined) {
18
+ // Add the custom certificate to the default roots rather than replacing them, so public-CA
19
+ // HTTPS targets keep working when a test/internal trust certificate is configured.
20
+ connect['ca'] = [...node_tls_1.rootCertificates, (0, node_fs_1.readFileSync)(options.trustCertificateFile, 'utf8')];
21
+ hasTls = true;
22
+ }
23
+ if (options.clientCertificate !== undefined) {
24
+ connect['cert'] = (0, node_fs_1.readFileSync)(options.clientCertificate.certificatePath);
25
+ connect['key'] = (0, node_fs_1.readFileSync)(options.clientCertificate.keyPath);
26
+ hasTls = true;
27
+ }
28
+ if (options.proxy !== undefined) {
29
+ return new undici_1.ProxyAgent({
30
+ uri: options.proxy,
31
+ ...(options.proxyAuthorization !== undefined ? { token: options.proxyAuthorization } : {}),
32
+ ...(hasTls ? { requestTls: connect } : {}),
33
+ });
34
+ }
35
+ if (hasTls) {
36
+ return new undici_1.Agent({ connect });
37
+ }
38
+ return undefined;
39
+ }
@@ -0,0 +1 @@
1
+ export declare const httpClientUserAgent: string;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.httpClientUserAgent = void 0;
5
+ // Version identity for outgoing requests. The single source of truth is the package.json
6
+ // version; the User-Agent product token is derived as zlink-http-client/<major.minor>.
7
+ // The relative path works both for the source tree and for the packaged dist/ layout.
8
+ const packageVersion = require('../../package.json').version;
9
+ exports.httpClientUserAgent = `zlink-http-client/${packageVersion
10
+ .split('.')
11
+ .slice(0, 2)
12
+ .join('.')}`;
@@ -0,0 +1,37 @@
1
+ /** HTTP methods supported by the ZLink HTTP client. */
2
+ export type ZLinkHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
3
+ /**
4
+ * Raw HTTP response with status, headers, and the buffered body as a string. Response header
5
+ * names are lowercase. Mirrors the C++ `raw_http_response_t`.
6
+ */
7
+ export interface RawHttpResponse {
8
+ readonly status: number;
9
+ readonly headers: Readonly<Record<string, string>>;
10
+ readonly body: string;
11
+ }
12
+ /**
13
+ * Typed HTTP response. `body` is the JSON-decoded payload; `rawBody` keeps the original response
14
+ * text. Mirrors the C++ `http_response_t<T>`.
15
+ */
16
+ export interface HttpResponse<T> {
17
+ readonly status: number;
18
+ readonly headers: Readonly<Record<string, string>>;
19
+ readonly body: T;
20
+ readonly rawBody: string;
21
+ }
22
+ /** Provider for a streamed request body; returns `null` when the body is complete. */
23
+ export type BodyChunkProvider = () => Uint8Array | null;
24
+ /** Sink for a streamed response download; receives chunks as they arrive (no decompression). */
25
+ export type DownloadSink = (chunk: Uint8Array) => void;
26
+ /** Completion scheduling seam supplied by a framework server integration. */
27
+ export interface ZLinkHttpExecutionTurn {
28
+ yieldPromise<T>(pending: Promise<T>): Promise<T>;
29
+ post(callback: () => void): void;
30
+ }
31
+ /** Captures the current framework execution turn when an HTTP call is built. */
32
+ export interface ZLinkHttpExecutionScheduler {
33
+ capture(): ZLinkHttpExecutionTurn | undefined;
34
+ reportError(error: unknown): void;
35
+ }
36
+ /** Callback completion path for callers that do not use an awaitable. */
37
+ export type ZLinkHttpCallback<T> = (error: unknown | undefined, response: HttpResponse<T> | undefined) => void;
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@zlink-systems/http-client",
3
+ "version": "0.10.0",
4
+ "license": "Apache-2.0",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "LICENSE"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "browser": "./dist/browser/index.mjs",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "dependencies": {
19
+ "@zlink-systems/framework": "0.10.0",
20
+ "undici": "6.27.0"
21
+ }
22
+ }