@zlink-systems/http-client 0.25.0 → 0.27.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.
@@ -2,12 +2,15 @@
2
2
  /* SPDX-License-Identifier: Apache-2.0 */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.RequestPerformer = void 0;
5
+ const compression_1 = require("./compression");
6
+ const text_1 = require("./text");
5
7
  const node_stream_1 = require("node:stream");
6
8
  const undici_1 = require("undici");
7
9
  const redirect_policy_1 = require("./redirect-policy");
8
10
  const response_body_reader_1 = require("./response-body-reader");
9
11
  const http_client_errors_1 = require("./http-client-errors");
10
12
  const version_1 = require("./version");
13
+ const retry_policy_1 = require("./retry-policy");
11
14
  /**
12
15
  * Performs a single logical request, driving the wrapper-owned redirect loop, cookie jar
13
16
  * integration, and same-origin `Authorization` scrubbing. Redirect/URL rules live in
@@ -39,17 +42,23 @@ class RequestPerformer {
39
42
  const keepAuthorization = current.origin === origin;
40
43
  const hasBody = body !== undefined || bodyProvider !== undefined;
41
44
  const headers = this.buildHeaders(spec, current, keepAuthorization, hasBody);
42
- const response = await (0, undici_1.request)(current.href, {
43
- method,
44
- headers,
45
- body: this.buildBody(body, bodyProvider),
46
- dispatcher: this.dispatcher,
47
- maxRedirections: 0,
48
- signal
49
- });
45
+ let response;
46
+ try {
47
+ response = await (0, undici_1.request)(current.href, {
48
+ method,
49
+ headers,
50
+ body: this.buildBody(body, bodyProvider, signal),
51
+ dispatcher: this.dispatcher,
52
+ maxRedirections: 0,
53
+ signal
54
+ });
55
+ }
56
+ catch (error) {
57
+ throw (0, retry_policy_1.mapFailure)(error, signal.aborted, retry_policy_1.HttpFailureStage.Transport);
58
+ }
50
59
  const status = response.statusCode;
51
60
  if (this.options.cookies) {
52
- const setCookie = response.headers['set-cookie'];
61
+ const setCookie = response.headers[text_1.HttpHeaderName.SetCookie];
53
62
  const values = Array.isArray(setCookie)
54
63
  ? setCookie
55
64
  : setCookie === undefined
@@ -59,28 +68,47 @@ class RequestPerformer {
59
68
  this.cookieJar.store(current.hostname, value);
60
69
  }
61
70
  }
62
- const location = headerValue(response.headers, 'location');
71
+ const location = headerValue(response.headers, text_1.HttpHeaderName.Location);
63
72
  if (this.options.followRedirects > 0 &&
64
73
  (0, redirect_policy_1.isRedirectStatus)(status) &&
65
74
  location !== undefined &&
66
75
  location.length > 0) {
67
76
  if (redirectsLeft === 0) {
68
- await drain(response.body);
77
+ await drain(response.body, signal);
69
78
  throw (0, http_client_errors_1.redirectLimitExceeded)();
70
79
  }
71
80
  redirectsLeft--;
72
81
  ({ method, body } = (0, redirect_policy_1.rewriteForRedirect)(status, method, body));
73
82
  bodyProvider = undefined; // consumed; never replay a non-rewindable stream on a redirect
74
- await drain(response.body);
83
+ await drain(response.body, signal);
75
84
  current = (0, redirect_policy_1.resolveLocation)(current, location);
76
85
  continue;
77
86
  }
78
87
  const collectedHeaders = response_body_reader_1.ResponseBodyReader.collectHeaders(response.headers);
79
88
  if (spec.sink !== undefined) {
80
- await this.bodyReader.streamToSink(response.body, spec.sink);
89
+ const sink = spec.sink;
90
+ try {
91
+ await this.bodyReader.streamToSink(response.body, (chunk) => {
92
+ try {
93
+ sink(chunk);
94
+ }
95
+ catch (error) {
96
+ throw (0, retry_policy_1.mapFailure)(error, signal.aborted, retry_policy_1.HttpFailureStage.Application);
97
+ }
98
+ });
99
+ }
100
+ catch (error) {
101
+ throw (0, retry_policy_1.mapFailure)(error, signal.aborted, retry_policy_1.HttpFailureStage.Transport);
102
+ }
81
103
  return { status, headers: collectedHeaders, body: '' };
82
104
  }
83
- let bytes = await this.bodyReader.readBuffered(response.body);
105
+ let bytes;
106
+ try {
107
+ bytes = await this.bodyReader.readBuffered(response.body);
108
+ }
109
+ catch (error) {
110
+ throw (0, retry_policy_1.mapFailure)(error, signal.aborted, retry_policy_1.HttpFailureStage.Transport);
111
+ }
84
112
  let finalHeaders = collectedHeaders;
85
113
  if (this.options.compression) {
86
114
  const result = await this.bodyReader.decompress(bytes, finalHeaders);
@@ -102,11 +130,11 @@ class RequestPerformer {
102
130
  }
103
131
  buildHeaders(spec, target, keepAuthorization, hasBody) {
104
132
  const headers = {
105
- 'user-agent': version_1.httpClientUserAgent,
106
- accept: 'application/json'
133
+ [text_1.HttpHeaderName.UserAgent]: version_1.httpClientUserAgent,
134
+ [text_1.HttpHeaderName.Accept]: text_1.HttpContentType.Json
107
135
  };
108
136
  if (this.options.compression) {
109
- headers['accept-encoding'] = 'gzip, deflate';
137
+ headers[text_1.HttpHeaderName.AcceptEncoding] = compression_1.HTTP_ACCEPTED_CONTENT_ENCODINGS;
110
138
  }
111
139
  // Proxy authentication is carried by the ProxyAgent dispatcher (see runtime.ts), not a header,
112
140
  // so it is not leaked to the target over a CONNECT tunnel.
@@ -114,19 +142,26 @@ class RequestPerformer {
114
142
  applyHeaders(headers, spec.headers, keepAuthorization);
115
143
  if (!hasBody) {
116
144
  // A body source dropped by a redirect must not leave stale content-type metadata behind.
117
- delete headers['content-type'];
145
+ delete headers[text_1.HttpHeaderName.ContentType];
118
146
  }
119
147
  if (this.options.cookies) {
120
148
  const cookieHeader = this.cookieJar.headerFor(target.hostname, target.pathname, target.protocol === 'https:');
121
149
  if (cookieHeader.length > 0) {
122
- headers['cookie'] = cookieHeader;
150
+ headers[text_1.HttpHeaderName.Cookie] = cookieHeader;
123
151
  }
124
152
  }
125
153
  return headers;
126
154
  }
127
- buildBody(body, bodyProvider) {
155
+ buildBody(body, bodyProvider, signal) {
128
156
  if (bodyProvider !== undefined) {
129
- const provider = bodyProvider;
157
+ const provider = () => {
158
+ try {
159
+ return bodyProvider();
160
+ }
161
+ catch (error) {
162
+ throw (0, retry_policy_1.mapFailure)(error, signal.aborted, retry_policy_1.HttpFailureStage.Application);
163
+ }
164
+ };
130
165
  return node_stream_1.Readable.from((function* () {
131
166
  for (let chunk = provider(); chunk !== null; chunk = provider()) {
132
167
  if (chunk.length > 0) {
@@ -142,7 +177,7 @@ exports.RequestPerformer = RequestPerformer;
142
177
  function applyHeaders(target, headers, keepAuthorization) {
143
178
  for (const [name, value] of Object.entries(headers)) {
144
179
  const lower = name.toLowerCase();
145
- if (!keepAuthorization && lower === 'authorization') {
180
+ if (!keepAuthorization && lower === text_1.HttpHeaderName.Authorization) {
146
181
  continue;
147
182
  }
148
183
  target[lower] = value;
@@ -155,8 +190,13 @@ function headerValue(headers, name) {
155
190
  }
156
191
  return Array.isArray(value) ? value[0] : value;
157
192
  }
158
- async function drain(stream) {
159
- for await (const _chunk of stream) {
160
- // discard
193
+ async function drain(stream, signal) {
194
+ try {
195
+ for await (const _chunk of stream) {
196
+ // discard
197
+ }
198
+ }
199
+ catch (error) {
200
+ throw (0, retry_policy_1.mapFailure)(error, signal.aborted, retry_policy_1.HttpFailureStage.Transport);
161
201
  }
162
202
  }
@@ -2,8 +2,9 @@
2
2
  /* SPDX-License-Identifier: Apache-2.0 */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.ResponseBodyReader = void 0;
5
+ const text_1 = require("./text");
5
6
  const compression_1 = require("./compression");
6
- const http_client_errors_1 = require("./http-client-errors");
7
+ const bounded_response_body_1 = require("./bounded-response-body");
7
8
  /**
8
9
  * Reads and decodes undici response bodies for the wrapper: buffered read with the configured size
9
10
  * limit, streaming delivery to a sink, header collection, and wrapper-controlled gzip/deflate
@@ -15,44 +16,33 @@ class ResponseBodyReader {
15
16
  this.options = options;
16
17
  }
17
18
  async streamToSink(stream, sink) {
18
- let total = 0;
19
- for await (const chunk of stream) {
20
- const buffer = chunk;
21
- total += buffer.length;
22
- if (total > this.options.maxResponseBodySize) {
23
- throw (0, http_client_errors_1.responseBodySizeExceeded)();
24
- }
19
+ await (0, bounded_response_body_1.readResponseBody)(stream, this.options.maxResponseBodySize, (buffer) => {
25
20
  sink(new Uint8Array(buffer));
26
- }
21
+ });
27
22
  }
28
23
  async readBuffered(stream) {
29
24
  const chunks = [];
30
- let total = 0;
31
- for await (const chunk of stream) {
32
- const buffer = chunk;
33
- total += buffer.length;
34
- if (total > this.options.maxResponseBodySize) {
35
- throw (0, http_client_errors_1.responseBodySizeExceeded)();
36
- }
25
+ await (0, bounded_response_body_1.readResponseBody)(stream, this.options.maxResponseBodySize, (buffer) => {
37
26
  chunks.push(buffer);
38
- }
27
+ });
39
28
  return Buffer.concat(chunks);
40
29
  }
41
30
  async decompress(bytes, headers) {
42
- const encoding = Object.prototype.hasOwnProperty.call(headers, 'content-encoding')
43
- ? headers['content-encoding']
31
+ const encoding = Object.prototype.hasOwnProperty.call(headers, text_1.HttpHeaderName.ContentEncoding)
32
+ ? headers[text_1.HttpHeaderName.ContentEncoding]
44
33
  : undefined;
45
34
  // An empty body (HEAD / 204 / 304) carries no payload to decode even with Content-Encoding.
46
35
  if (encoding === undefined || bytes.length === 0) {
47
36
  return { body: bytes, headers };
48
37
  }
49
- if (encoding.toLowerCase() === 'gzip') {
38
+ const normalizedEncoding = encoding.toLowerCase();
39
+ if (normalizedEncoding === compression_1.CONTENT_ENCODING.Gzip) {
50
40
  return {
51
41
  body: await (0, compression_1.gunzip)(bytes, this.options.maxResponseBodySize),
52
42
  headers: stripEncodingHeaders(headers)
53
43
  };
54
44
  }
55
- if (encoding.toLowerCase() === 'deflate') {
45
+ if (normalizedEncoding === compression_1.CONTENT_ENCODING.Deflate) {
56
46
  return {
57
47
  body: await (0, compression_1.inflateDeflate)(bytes, this.options.maxResponseBodySize),
58
48
  headers: stripEncodingHeaders(headers)
@@ -78,7 +68,7 @@ function stripEncodingHeaders(headers) {
78
68
  const copy = {};
79
69
  for (const [key, value] of Object.entries(headers)) {
80
70
  const lower = key.toLowerCase();
81
- if (lower !== 'content-encoding' && lower !== 'content-length') {
71
+ if (lower !== text_1.HttpHeaderName.ContentEncoding && lower !== text_1.HttpHeaderName.ContentLength) {
82
72
  copy[key] = value;
83
73
  }
84
74
  }
@@ -1,3 +1,4 @@
1
+ import { ZLinkFrameworkException } from '@zlink-systems/framework';
1
2
  import type { HttpClientOptions } from './options';
2
3
  import type { HttpRequestSpec, RawResult } from './request-performer';
3
4
  /**
@@ -10,5 +11,10 @@ import type { HttpRequestSpec, RawResult } from './request-performer';
10
11
  export declare class RetryPolicy {
11
12
  private readonly options;
12
13
  constructor(options: HttpClientOptions);
13
- execute(spec: HttpRequestSpec, perform: (spec: HttpRequestSpec, signal: AbortSignal) => Promise<RawResult>): Promise<RawResult>;
14
+ execute(spec: HttpRequestSpec, perform: (spec: HttpRequestSpec, controller: AbortController) => Promise<RawResult>): Promise<RawResult>;
14
15
  }
16
+ export declare enum HttpFailureStage {
17
+ Transport = 0,
18
+ Application = 1
19
+ }
20
+ export declare function mapFailure(error: unknown, aborted: boolean, stage: HttpFailureStage): ZLinkFrameworkException;
@@ -1,12 +1,16 @@
1
1
  "use strict";
2
2
  /* SPDX-License-Identifier: Apache-2.0 */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.RetryPolicy = void 0;
4
+ exports.HttpFailureStage = exports.RetryPolicy = void 0;
5
+ exports.mapFailure = mapFailure;
5
6
  const framework_1 = require("@zlink-systems/framework");
7
+ const INITIAL_RETRY_DELAY_MS = 50;
8
+ const MAX_RETRY_DELAY_MS = 1000;
9
+ const MAX_RETRY_DELAY_SHIFT = Math.ceil(Math.log2(MAX_RETRY_DELAY_MS / INITIAL_RETRY_DELAY_MS));
6
10
  // Exponential backoff with full jitter: base 50ms, doubling per attempt, capped at 1s.
7
11
  // Fixed delays synchronize retries from many clients against an ailing server.
8
12
  function delayMsFor(attempt) {
9
- const ceiling = Math.min(1000, 50 << Math.min(attempt, 5));
13
+ const ceiling = Math.min(MAX_RETRY_DELAY_MS, INITIAL_RETRY_DELAY_MS << Math.min(attempt, MAX_RETRY_DELAY_SHIFT));
10
14
  return Math.floor(Math.random() * (ceiling + 1));
11
15
  }
12
16
  /**
@@ -30,10 +34,10 @@ class RetryPolicy {
30
34
  controller.abort();
31
35
  }, timeoutMs);
32
36
  try {
33
- return await perform(spec, controller.signal);
37
+ return await perform(spec, controller);
34
38
  }
35
39
  catch (error) {
36
- const failure = mapFailure(error, controller.signal.aborted);
40
+ const failure = mapFailure(error, controller.signal.aborted, HttpFailureStage.Application);
37
41
  if (isRetriableHttpFailure(failure) && attempt < maxRetries) {
38
42
  await delay(delayMsFor(attempt));
39
43
  continue;
@@ -47,16 +51,23 @@ class RetryPolicy {
47
51
  }
48
52
  }
49
53
  exports.RetryPolicy = RetryPolicy;
50
- function mapFailure(error, aborted) {
54
+ var HttpFailureStage;
55
+ (function (HttpFailureStage) {
56
+ HttpFailureStage[HttpFailureStage["Transport"] = 0] = "Transport";
57
+ HttpFailureStage[HttpFailureStage["Application"] = 1] = "Application";
58
+ })(HttpFailureStage || (exports.HttpFailureStage = HttpFailureStage = {}));
59
+ function mapFailure(error, aborted, stage) {
51
60
  if (error instanceof framework_1.ZLinkFrameworkException) {
52
61
  return error;
53
62
  }
54
- if (aborted || (error instanceof Error && error.name === 'AbortError')) {
55
- return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.DeadlineExceeded, 'HTTP request exceeded timeout', error);
63
+ if (stage === HttpFailureStage.Transport && aborted) {
64
+ const cause = new DOMException('HTTP request exceeded timeout', 'TimeoutError');
65
+ return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.DeadlineExceeded, cause.message, cause);
56
66
  }
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);
67
+ const message = error instanceof Error ? error.message : 'HTTP execution failure';
68
+ return new framework_1.ZLinkFrameworkException(stage === HttpFailureStage.Transport
69
+ ? framework_1.ZLinkFrameworkErrorKind.Unavailable
70
+ : framework_1.ZLinkFrameworkErrorKind.InternalFailure, message, error);
60
71
  }
61
72
  function isRetriableHttpFailure(error) {
62
73
  return (error.kind === framework_1.ZLinkFrameworkErrorKind.Unavailable ||
@@ -22,7 +22,7 @@ class HttpClientRuntime {
22
22
  this.retryPolicy = new retry_policy_1.RetryPolicy(options);
23
23
  }
24
24
  async executeAsync(spec) {
25
- return this.retryPolicy.execute(spec, (request, signal) => this.performer.perform(request, signal));
25
+ return this.retryPolicy.execute(spec, (request, controller) => this.performer.perform(request, controller.signal));
26
26
  }
27
27
  async close() {
28
28
  if (this.dispatcher !== undefined) {
@@ -1,3 +1,20 @@
1
+ export declare const HttpHeaderName: Readonly<{
2
+ Accept: "accept";
3
+ Authorization: "authorization";
4
+ ContentType: "content-type";
5
+ Location: "location";
6
+ SetCookie: "set-cookie";
7
+ UserAgent: "user-agent";
8
+ AcceptEncoding: "accept-encoding";
9
+ Cookie: "cookie";
10
+ ContentEncoding: "content-encoding";
11
+ ContentLength: "content-length";
12
+ }>;
13
+ export declare const HttpContentType: Readonly<{
14
+ Json: "application/json";
15
+ UrlEncoded: "application/x-www-form-urlencoded";
16
+ Multipart: "multipart/form-data";
17
+ }>;
1
18
  export declare function requireNonBlank(value: string, message: string): void;
2
19
  export declare function requirePositiveTimeout(value: number): void;
3
20
  export declare function percentEncode(value: string): string;
@@ -1,12 +1,30 @@
1
1
  "use strict";
2
2
  /* SPDX-License-Identifier: Apache-2.0 */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.HttpContentType = exports.HttpHeaderName = void 0;
4
5
  exports.requireNonBlank = requireNonBlank;
5
6
  exports.requirePositiveTimeout = requirePositiveTimeout;
6
7
  exports.percentEncode = percentEncode;
7
8
  exports.basicAuthorization = basicAuthorization;
8
9
  exports.makeMultipartBoundary = makeMultipartBoundary;
9
10
  const framework_1 = require("@zlink-systems/framework");
11
+ exports.HttpHeaderName = Object.freeze({
12
+ Accept: 'accept',
13
+ Authorization: 'authorization',
14
+ ContentType: 'content-type',
15
+ Location: 'location',
16
+ SetCookie: 'set-cookie',
17
+ UserAgent: 'user-agent',
18
+ AcceptEncoding: 'accept-encoding',
19
+ Cookie: 'cookie',
20
+ ContentEncoding: 'content-encoding',
21
+ ContentLength: 'content-length'
22
+ });
23
+ exports.HttpContentType = Object.freeze({
24
+ Json: 'application/json',
25
+ UrlEncoded: 'application/x-www-form-urlencoded',
26
+ Multipart: 'multipart/form-data'
27
+ });
10
28
  /** Shared text helpers mirroring the C++ `client.cpp` anonymous-namespace utilities. */
11
29
  function isBlank(value) {
12
30
  return value.length === 0 || /^[\s]*$/u.test(value);
@@ -39,6 +57,13 @@ function percentEncode(value) {
39
57
  function basicAuthorization(user, password) {
40
58
  return 'Basic ' + Buffer.from(`${user}:${password}`, 'utf8').toString('base64');
41
59
  }
60
+ const MULTIPART_BOUNDARY_PREFIX = 'zlink-boundary-';
61
+ const MULTIPART_BOUNDARY_ENTROPY_CHARACTERS = 16;
42
62
  function makeMultipartBoundary() {
43
- return 'zlink-boundary-' + Math.random().toString(16).slice(2).padEnd(16, '0').slice(0, 16);
63
+ return (MULTIPART_BOUNDARY_PREFIX +
64
+ Math.random()
65
+ .toString(16)
66
+ .slice(2)
67
+ .padEnd(MULTIPART_BOUNDARY_ENTROPY_CHARACTERS, '0')
68
+ .slice(0, MULTIPART_BOUNDARY_ENTROPY_CHARACTERS));
44
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zlink-systems/http-client",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,7 +22,7 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@zlink-systems/framework": "0.25.0",
25
+ "@zlink-systems/framework": "0.27.0",
26
26
  "undici": "6.27.0"
27
27
  }
28
28
  }