@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,123 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.HttpClientRuntime = void 0;
5
+ const framework_1 = require("@zlink-systems/framework");
6
+ const redirect_policy_1 = require("./redirect-policy");
7
+ const retry_policy_1 = require("./retry-policy");
8
+ /** Browser transport for the same public client surface used by the Node runtime. */
9
+ class HttpClientRuntime {
10
+ options;
11
+ retryPolicy;
12
+ constructor(options) {
13
+ this.options = options;
14
+ rejectUnsupportedTransportOptions(options);
15
+ this.retryPolicy = new retry_policy_1.RetryPolicy(options);
16
+ }
17
+ async executeAsync(spec) {
18
+ return await this.retryPolicy.execute(spec, (request, signal) => this.perform(request, signal));
19
+ }
20
+ async close() { }
21
+ async perform(spec, signal) {
22
+ const baseUri = new URL(this.options.baseUrl);
23
+ const origin = baseUri.origin;
24
+ let current = new URL(origin + (0, redirect_policy_1.makeTarget)(baseUri.pathname, spec.target));
25
+ let method = spec.method;
26
+ let body = spec.body;
27
+ let bodyProvider = spec.bodyProvider;
28
+ let redirectsLeft = this.options.followRedirects;
29
+ for (;;) {
30
+ const headers = this.buildHeaders(spec, current.origin === origin, body !== undefined || bodyProvider !== undefined);
31
+ const response = await fetch(current, {
32
+ method,
33
+ headers,
34
+ body: body ?? bodyStream(bodyProvider),
35
+ credentials: this.options.cookies ? 'include' : 'same-origin',
36
+ redirect: 'manual',
37
+ signal,
38
+ });
39
+ const location = response.headers.get('location');
40
+ if (this.options.followRedirects > 0 && (0, redirect_policy_1.isRedirectStatus)(response.status) && location !== null) {
41
+ if (redirectsLeft === 0) {
42
+ await response.body?.cancel();
43
+ throw requestError('HTTP request exceeded the redirect limit');
44
+ }
45
+ redirectsLeft--;
46
+ ({ method, body } = (0, redirect_policy_1.rewriteForRedirect)(response.status, method, body));
47
+ bodyProvider = undefined;
48
+ await response.body?.cancel();
49
+ current = (0, redirect_policy_1.resolveLocation)(current, location);
50
+ continue;
51
+ }
52
+ const headersResult = collectHeaders(response.headers);
53
+ if (spec.sink !== undefined) {
54
+ await streamResponse(response, spec.sink, this.options.maxResponseBodySize);
55
+ return { status: response.status, headers: headersResult, body: '' };
56
+ }
57
+ const text = await response.text();
58
+ if (new TextEncoder().encode(text).length > this.options.maxResponseBodySize) {
59
+ throw requestError('HTTP response exceeded the maximum body size');
60
+ }
61
+ return { status: response.status, headers: headersResult, body: text };
62
+ }
63
+ }
64
+ buildHeaders(spec, keepAuthorization, hasBody) {
65
+ const headers = { accept: 'application/json' };
66
+ applyHeaders(headers, this.options.headers, keepAuthorization);
67
+ applyHeaders(headers, spec.headers, keepAuthorization);
68
+ if (!hasBody)
69
+ delete headers['content-type'];
70
+ return headers;
71
+ }
72
+ }
73
+ exports.HttpClientRuntime = HttpClientRuntime;
74
+ function rejectUnsupportedTransportOptions(options) {
75
+ if (options.trustCertificateFile !== undefined || options.clientCertificate !== undefined || options.proxy !== undefined) {
76
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.ProtocolError, 'Browser HTTP clients cannot configure certificate files or a transport proxy.');
77
+ }
78
+ }
79
+ function bodyStream(provider) {
80
+ if (provider === undefined)
81
+ return undefined;
82
+ return new ReadableStream({
83
+ pull(controller) {
84
+ const chunk = provider();
85
+ if (chunk === null)
86
+ controller.close();
87
+ else
88
+ controller.enqueue(chunk);
89
+ },
90
+ });
91
+ }
92
+ async function streamResponse(response, sink, maximumSize) {
93
+ if (response.body === null)
94
+ return;
95
+ const reader = response.body.getReader();
96
+ let total = 0;
97
+ for (;;) {
98
+ const result = await reader.read();
99
+ if (result.done)
100
+ return;
101
+ total += result.value.length;
102
+ if (total > maximumSize) {
103
+ await reader.cancel();
104
+ throw requestError('HTTP response exceeded the maximum body size');
105
+ }
106
+ sink(result.value);
107
+ }
108
+ }
109
+ function collectHeaders(headers) {
110
+ const result = {};
111
+ headers.forEach((value, name) => { result[name.toLowerCase()] = value; });
112
+ return result;
113
+ }
114
+ function applyHeaders(target, source, keepAuthorization) {
115
+ for (const [name, value] of Object.entries(source)) {
116
+ const lower = name.toLowerCase();
117
+ if (keepAuthorization || lower !== 'authorization')
118
+ target[lower] = value;
119
+ }
120
+ }
121
+ function requestError(message) {
122
+ return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, message);
123
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wrapper-controlled response decompression mirroring the C++ `compression.cpp`: gzip and deflate
3
+ * are decoded, the decoded size is bounded by the configured body limit (enforced by zlib's
4
+ * `maxOutputLength` so a malicious response cannot allocate past the limit before the check), and the
5
+ * caller removes the `Content-Encoding` header afterwards. undici's `request` does not auto-decompress,
6
+ * so streaming downloads are never transparently decoded. A malformed body raises `payloadDecodeFailed`;
7
+ * exceeding the limit raises `requestFailed`. Decoding runs on zlib's async worker pool — the
8
+ * synchronous variants would block the event loop for the whole decode of a large body.
9
+ */
10
+ export declare function gunzip(input: Buffer, maxBytes: number): Promise<Buffer>;
11
+ export declare function inflateDeflate(input: Buffer, maxBytes: number): Promise<Buffer>;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.gunzip = gunzip;
5
+ exports.inflateDeflate = inflateDeflate;
6
+ const node_zlib_1 = require("node:zlib");
7
+ const node_util_1 = require("node:util");
8
+ const framework_1 = require("@zlink-systems/framework");
9
+ const gunzipAsync = (0, node_util_1.promisify)(node_zlib_1.gunzip);
10
+ const inflateAsync = (0, node_util_1.promisify)(node_zlib_1.inflate);
11
+ const inflateRawAsync = (0, node_util_1.promisify)(node_zlib_1.inflateRaw);
12
+ /**
13
+ * Wrapper-controlled response decompression mirroring the C++ `compression.cpp`: gzip and deflate
14
+ * are decoded, the decoded size is bounded by the configured body limit (enforced by zlib's
15
+ * `maxOutputLength` so a malicious response cannot allocate past the limit before the check), and the
16
+ * caller removes the `Content-Encoding` header afterwards. undici's `request` does not auto-decompress,
17
+ * so streaming downloads are never transparently decoded. A malformed body raises `payloadDecodeFailed`;
18
+ * exceeding the limit raises `requestFailed`. Decoding runs on zlib's async worker pool — the
19
+ * synchronous variants would block the event loop for the whole decode of a large body.
20
+ */
21
+ function gunzip(input, maxBytes) {
22
+ return decode(() => gunzipAsync(input, { maxOutputLength: maxBytes, chunkSize: 1 << 20 }));
23
+ }
24
+ function inflateDeflate(input, maxBytes) {
25
+ // Detect a zlib-wrapped stream (CMF/FLG: method deflate, header a multiple of 31) vs raw deflate.
26
+ const zlibWrapped = input.length >= 2 && (input[0] & 0x0f) === 8 && (((input[0] << 8) | input[1]) % 31) === 0;
27
+ return decode(() => zlibWrapped
28
+ ? inflateAsync(input, { maxOutputLength: maxBytes, chunkSize: 1 << 20 })
29
+ : inflateRawAsync(input, { maxOutputLength: maxBytes, chunkSize: 1 << 20 }));
30
+ }
31
+ async function decode(run) {
32
+ try {
33
+ return await run();
34
+ }
35
+ catch (cause) {
36
+ // zlib rejects with a RangeError (ERR_BUFFER_TOO_LARGE) when output exceeds maxOutputLength.
37
+ if (cause instanceof RangeError) {
38
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, 'HTTP response compressed body exceeds maxResponseBodySize');
39
+ }
40
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.ProtocolError, 'HTTP response compressed body is malformed', cause);
41
+ }
42
+ }
@@ -0,0 +1,5 @@
1
+ export declare class CookieJar {
2
+ private readonly cookies;
3
+ store(host: string, setCookieHeader: string): void;
4
+ headerFor(host: string, path: string, secure: boolean): string;
5
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.CookieJar = void 0;
5
+ const MAX_COOKIES_PER_HOST = 128;
6
+ class CookieJar {
7
+ cookies = [];
8
+ store(host, setCookieHeader) {
9
+ const segments = setCookieHeader.split(';');
10
+ const pair = (segments.shift() ?? '').trim();
11
+ const equals = pair.indexOf('=');
12
+ if (equals < 0) {
13
+ return;
14
+ }
15
+ const name = pair.slice(0, equals).trim();
16
+ const value = pair.slice(equals + 1).trim();
17
+ if (name.length === 0) {
18
+ return;
19
+ }
20
+ let path = '/';
21
+ let secure = false;
22
+ let expired = false;
23
+ for (const raw of segments) {
24
+ const attribute = raw.trim();
25
+ const attrEquals = attribute.indexOf('=');
26
+ const attrName = (attrEquals < 0 ? attribute : attribute.slice(0, attrEquals)).trim();
27
+ const attrValue = attrEquals < 0 ? '' : attribute.slice(attrEquals + 1).trim();
28
+ if (attrName.toLowerCase() === 'path' && attrValue.length > 0) {
29
+ path = attrValue;
30
+ }
31
+ else if (attrName.toLowerCase() === 'secure') {
32
+ secure = true;
33
+ }
34
+ else if (attrName.toLowerCase() === 'max-age') {
35
+ const maxAge = Number.parseInt(attrValue, 10);
36
+ if (!Number.isNaN(maxAge)) {
37
+ expired = maxAge <= 0;
38
+ }
39
+ }
40
+ }
41
+ for (let i = this.cookies.length - 1; i >= 0; i--) {
42
+ const existing = this.cookies[i];
43
+ if (existing.host === host && existing.name === name && existing.path === path) {
44
+ this.cookies.splice(i, 1);
45
+ }
46
+ }
47
+ if (expired) {
48
+ return;
49
+ }
50
+ this.cookies.push({ host, name, value, path, secure });
51
+ let countForHost = this.cookies.filter((stored) => stored.host === host).length;
52
+ while (countForHost > MAX_COOKIES_PER_HOST) {
53
+ const oldest = this.cookies.findIndex((stored) => stored.host === host);
54
+ if (oldest < 0) {
55
+ break;
56
+ }
57
+ this.cookies.splice(oldest, 1);
58
+ countForHost--;
59
+ }
60
+ }
61
+ headerFor(host, path, secure) {
62
+ const parts = [];
63
+ for (const cookie of this.cookies) {
64
+ if (cookie.host !== host || (cookie.secure && !secure) || !pathMatches(path, cookie.path)) {
65
+ continue;
66
+ }
67
+ parts.push(`${cookie.name}=${cookie.value}`);
68
+ }
69
+ return parts.join('; ');
70
+ }
71
+ }
72
+ exports.CookieJar = CookieJar;
73
+ function pathMatches(requestPath, cookiePath) {
74
+ if (cookiePath.length === 0 || cookiePath === '/') {
75
+ return true;
76
+ }
77
+ if (requestPath === cookiePath) {
78
+ return true;
79
+ }
80
+ if (!requestPath.startsWith(cookiePath)) {
81
+ return false;
82
+ }
83
+ return cookiePath.endsWith('/') || requestPath[cookiePath.length] === '/';
84
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Immutable snapshot of the client configuration produced by the builder. Mirrors the C++
3
+ * `http_client_options_t`. Transport gaps (cookie jar, redirect loop, compression, retry) are
4
+ * implemented by the wrapper; only matching semantics are delegated to undici.
5
+ */
6
+ export interface HttpClientOptions {
7
+ readonly baseUrl: string;
8
+ readonly timeoutMs: number;
9
+ readonly maxResponseBodySize: number;
10
+ readonly headers: Readonly<Record<string, string>>;
11
+ readonly trustCertificateFile?: string;
12
+ readonly clientCertificate?: {
13
+ readonly certificatePath: string;
14
+ readonly keyPath: string;
15
+ };
16
+ readonly followRedirects: number;
17
+ readonly retryAttempts: number;
18
+ readonly cookies: boolean;
19
+ readonly proxy?: string;
20
+ readonly proxyAuthorization?: string;
21
+ readonly compression: boolean;
22
+ }
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,13 @@
1
+ import type { ZLinkHttpMethod } from '../types';
2
+ export declare function isRedirectStatus(status: number): boolean;
3
+ /** Combines the base URL path prefix with the request target. */
4
+ export declare function makeTarget(prefix: string, path: string): string;
5
+ /**
6
+ * Applies the method/body rewrite for a followed redirect: 303, and 301/302 on POST, become a
7
+ * bodyless GET; all other redirects preserve the method and body.
8
+ */
9
+ export declare function rewriteForRedirect(status: number, method: ZLinkHttpMethod, body: string | undefined): {
10
+ method: ZLinkHttpMethod;
11
+ body: string | undefined;
12
+ };
13
+ export declare function resolveLocation(current: URL, location: string): URL;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.isRedirectStatus = isRedirectStatus;
5
+ exports.makeTarget = makeTarget;
6
+ exports.rewriteForRedirect = rewriteForRedirect;
7
+ exports.resolveLocation = resolveLocation;
8
+ const framework_1 = require("@zlink-systems/framework");
9
+ /**
10
+ * Stateless redirect and target-URL rules for the wrapper-owned redirect loop. Isolates the redirect
11
+ * contract (allowed statuses, location resolution, method rewrite) from the request flow.
12
+ */
13
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
14
+ function isRedirectStatus(status) {
15
+ return REDIRECT_STATUSES.has(status);
16
+ }
17
+ /** Combines the base URL path prefix with the request target. */
18
+ function makeTarget(prefix, path) {
19
+ if (prefix.length === 0 || prefix === '/') {
20
+ return path;
21
+ }
22
+ return prefix.endsWith('/') ? prefix.slice(0, -1) + path : prefix + path;
23
+ }
24
+ /**
25
+ * Applies the method/body rewrite for a followed redirect: 303, and 301/302 on POST, become a
26
+ * bodyless GET; all other redirects preserve the method and body.
27
+ */
28
+ function rewriteForRedirect(status, method, body) {
29
+ if (status === 303 || ((status === 301 || status === 302) && method === 'POST')) {
30
+ return { method: 'GET', body: undefined };
31
+ }
32
+ return { method, body };
33
+ }
34
+ function resolveLocation(current, location) {
35
+ try {
36
+ if (location.startsWith('http://') || location.startsWith('https://')) {
37
+ return new URL(location);
38
+ }
39
+ if (location.startsWith('//')) {
40
+ // Protocol-relative location: inherit the current scheme (current.protocol includes the ':').
41
+ return new URL(current.protocol + location);
42
+ }
43
+ if (location.startsWith('/')) {
44
+ return new URL(current.origin + location);
45
+ }
46
+ }
47
+ catch {
48
+ // A malformed Location is a redirect-protocol error, not a transport failure: throw a
49
+ // non-retriable framework exception so retry does not resend the original request.
50
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, `HTTP redirect location is malformed: ${location}`);
51
+ }
52
+ throw new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, `HTTP redirect location is not supported: ${location}`);
53
+ }
@@ -0,0 +1,35 @@
1
+ import { type Dispatcher } from 'undici';
2
+ import type { BodyChunkProvider, DownloadSink, ZLinkHttpMethod } from '../types';
3
+ import type { HttpClientOptions } from './options';
4
+ import { CookieJar } from './cookie-jar';
5
+ export interface HttpRequestSpec {
6
+ readonly method: ZLinkHttpMethod;
7
+ readonly target: string;
8
+ readonly body?: string;
9
+ readonly bodyProvider?: BodyChunkProvider;
10
+ readonly headers: Readonly<Record<string, string>>;
11
+ readonly timeoutMs?: number;
12
+ readonly sink?: DownloadSink;
13
+ }
14
+ export interface RawResult {
15
+ readonly status: number;
16
+ readonly headers: Record<string, string>;
17
+ readonly body: string;
18
+ }
19
+ /**
20
+ * Performs a single logical request, driving the wrapper-owned redirect loop, cookie jar
21
+ * integration, and same-origin `Authorization` scrubbing. Redirect/URL rules live in
22
+ * `redirect-policy` and response decoding in {@link ResponseBodyReader}. Mirrors the C++
23
+ * `request_performer.cpp`. undici does not auto-redirect, auto-decompress, or keep cookies, so these
24
+ * semantics match the ZLink contract.
25
+ */
26
+ export declare class RequestPerformer {
27
+ private readonly options;
28
+ private readonly cookieJar;
29
+ private readonly dispatcher;
30
+ private readonly bodyReader;
31
+ constructor(options: HttpClientOptions, cookieJar: CookieJar, dispatcher: Dispatcher | undefined);
32
+ perform(spec: HttpRequestSpec, signal: AbortSignal): Promise<RawResult>;
33
+ private buildHeaders;
34
+ private buildBody;
35
+ }
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.RequestPerformer = void 0;
5
+ const node_stream_1 = require("node:stream");
6
+ const undici_1 = require("undici");
7
+ const framework_1 = require("@zlink-systems/framework");
8
+ const redirect_policy_1 = require("./redirect-policy");
9
+ const response_body_reader_1 = require("./response-body-reader");
10
+ const version_1 = require("./version");
11
+ /**
12
+ * Performs a single logical request, driving the wrapper-owned redirect loop, cookie jar
13
+ * integration, and same-origin `Authorization` scrubbing. Redirect/URL rules live in
14
+ * `redirect-policy` and response decoding in {@link ResponseBodyReader}. Mirrors the C++
15
+ * `request_performer.cpp`. undici does not auto-redirect, auto-decompress, or keep cookies, so these
16
+ * semantics match the ZLink contract.
17
+ */
18
+ class RequestPerformer {
19
+ options;
20
+ cookieJar;
21
+ dispatcher;
22
+ bodyReader;
23
+ constructor(options, cookieJar, dispatcher) {
24
+ this.options = options;
25
+ this.cookieJar = cookieJar;
26
+ this.dispatcher = dispatcher;
27
+ this.bodyReader = new response_body_reader_1.ResponseBodyReader(options);
28
+ }
29
+ async perform(spec, signal) {
30
+ const baseUri = new URL(this.options.baseUrl);
31
+ const origin = baseUri.origin;
32
+ let current = new URL(origin + (0, redirect_policy_1.makeTarget)(baseUri.pathname, spec.target));
33
+ let method = spec.method;
34
+ let body = spec.body;
35
+ // A streamed body provider cannot be rewound, so it is dropped once a redirect is followed.
36
+ let bodyProvider = spec.bodyProvider;
37
+ let redirectsLeft = this.options.followRedirects;
38
+ for (;;) {
39
+ const keepAuthorization = current.origin === origin;
40
+ const hasBody = body !== undefined || bodyProvider !== undefined;
41
+ 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
+ });
50
+ const status = response.statusCode;
51
+ if (this.options.cookies) {
52
+ const setCookie = response.headers['set-cookie'];
53
+ const values = Array.isArray(setCookie) ? setCookie : setCookie === undefined ? [] : [setCookie];
54
+ for (const value of values) {
55
+ this.cookieJar.store(current.hostname, value);
56
+ }
57
+ }
58
+ const location = headerValue(response.headers, 'location');
59
+ if (this.options.followRedirects > 0 && (0, redirect_policy_1.isRedirectStatus)(status) && location !== undefined && location.length > 0) {
60
+ if (redirectsLeft === 0) {
61
+ await drain(response.body);
62
+ throw requestError('HTTP request exceeded the redirect limit');
63
+ }
64
+ redirectsLeft--;
65
+ ({ method, body } = (0, redirect_policy_1.rewriteForRedirect)(status, method, body));
66
+ bodyProvider = undefined; // consumed; never replay a non-rewindable stream on a redirect
67
+ await drain(response.body);
68
+ current = (0, redirect_policy_1.resolveLocation)(current, location);
69
+ continue;
70
+ }
71
+ const collectedHeaders = response_body_reader_1.ResponseBodyReader.collectHeaders(response.headers);
72
+ if (spec.sink !== undefined) {
73
+ await this.bodyReader.streamToSink(response.body, spec.sink);
74
+ return { status, headers: collectedHeaders, body: '' };
75
+ }
76
+ let bytes = await this.bodyReader.readBuffered(response.body);
77
+ let finalHeaders = collectedHeaders;
78
+ if (this.options.compression) {
79
+ const result = await this.bodyReader.decompress(bytes, finalHeaders);
80
+ bytes = result.body;
81
+ finalHeaders = result.headers;
82
+ }
83
+ // Decode lazily: the UTF-8 conversion of a large body is a main-thread cost that
84
+ // callers who never read the text body (binary consumers) should not pay.
85
+ let text;
86
+ return {
87
+ status,
88
+ headers: finalHeaders,
89
+ get body() {
90
+ text ??= bytes.toString('utf8');
91
+ return text;
92
+ },
93
+ };
94
+ }
95
+ }
96
+ buildHeaders(spec, target, keepAuthorization, hasBody) {
97
+ const headers = {
98
+ 'user-agent': version_1.httpClientUserAgent,
99
+ accept: 'application/json',
100
+ };
101
+ if (this.options.compression) {
102
+ headers['accept-encoding'] = 'gzip, deflate';
103
+ }
104
+ // Proxy authentication is carried by the ProxyAgent dispatcher (see runtime.ts), not a header,
105
+ // so it is not leaked to the target over a CONNECT tunnel.
106
+ applyHeaders(headers, this.options.headers, keepAuthorization);
107
+ applyHeaders(headers, spec.headers, keepAuthorization);
108
+ if (!hasBody) {
109
+ // A body source dropped by a redirect must not leave stale content-type metadata behind.
110
+ delete headers['content-type'];
111
+ }
112
+ if (this.options.cookies) {
113
+ const cookieHeader = this.cookieJar.headerFor(target.hostname, target.pathname, target.protocol === 'https:');
114
+ if (cookieHeader.length > 0) {
115
+ headers['cookie'] = cookieHeader;
116
+ }
117
+ }
118
+ return headers;
119
+ }
120
+ buildBody(body, bodyProvider) {
121
+ if (bodyProvider !== undefined) {
122
+ const provider = bodyProvider;
123
+ return node_stream_1.Readable.from((function* () {
124
+ for (let chunk = provider(); chunk !== null; chunk = provider()) {
125
+ if (chunk.length > 0) {
126
+ yield chunk;
127
+ }
128
+ }
129
+ })());
130
+ }
131
+ return body;
132
+ }
133
+ }
134
+ exports.RequestPerformer = RequestPerformer;
135
+ function applyHeaders(target, headers, keepAuthorization) {
136
+ for (const [name, value] of Object.entries(headers)) {
137
+ const lower = name.toLowerCase();
138
+ if (!keepAuthorization && lower === 'authorization') {
139
+ continue;
140
+ }
141
+ target[lower] = value;
142
+ }
143
+ }
144
+ function headerValue(headers, name) {
145
+ const value = headers[name];
146
+ if (value === undefined) {
147
+ return undefined;
148
+ }
149
+ return Array.isArray(value) ? value[0] : value;
150
+ }
151
+ async function drain(stream) {
152
+ for await (const _chunk of stream) {
153
+ // discard
154
+ }
155
+ }
156
+ function requestError(message) {
157
+ return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, message);
158
+ }
@@ -0,0 +1,19 @@
1
+ import type { Readable } from 'node:stream';
2
+ import type { DownloadSink } from '../types';
3
+ import type { HttpClientOptions } from './options';
4
+ /**
5
+ * Reads and decodes undici response bodies for the wrapper: buffered read with the configured size
6
+ * limit, streaming delivery to a sink, header collection, and wrapper-controlled gzip/deflate
7
+ * decompression. Separated from the request/redirect flow so response decoding is independent.
8
+ */
9
+ export declare class ResponseBodyReader {
10
+ private readonly options;
11
+ constructor(options: HttpClientOptions);
12
+ streamToSink(stream: Readable, sink: DownloadSink): Promise<void>;
13
+ readBuffered(stream: Readable): Promise<Buffer>;
14
+ decompress(bytes: Buffer, headers: Record<string, string>): Promise<{
15
+ body: Buffer;
16
+ headers: Record<string, string>;
17
+ }>;
18
+ static collectHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string>;
19
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ResponseBodyReader = void 0;
5
+ const framework_1 = require("@zlink-systems/framework");
6
+ const compression_1 = require("./compression");
7
+ /**
8
+ * Reads and decodes undici response bodies for the wrapper: buffered read with the configured size
9
+ * limit, streaming delivery to a sink, header collection, and wrapper-controlled gzip/deflate
10
+ * decompression. Separated from the request/redirect flow so response decoding is independent.
11
+ */
12
+ class ResponseBodyReader {
13
+ options;
14
+ constructor(options) {
15
+ this.options = options;
16
+ }
17
+ 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 exceededBodySize();
24
+ }
25
+ sink(new Uint8Array(buffer));
26
+ }
27
+ }
28
+ async readBuffered(stream) {
29
+ 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 exceededBodySize();
36
+ }
37
+ chunks.push(buffer);
38
+ }
39
+ return Buffer.concat(chunks);
40
+ }
41
+ async decompress(bytes, headers) {
42
+ const encoding = Object.prototype.hasOwnProperty.call(headers, 'content-encoding')
43
+ ? headers['content-encoding']
44
+ : undefined;
45
+ // An empty body (HEAD / 204 / 304) carries no payload to decode even with Content-Encoding.
46
+ if (encoding === undefined || bytes.length === 0) {
47
+ return { body: bytes, headers };
48
+ }
49
+ if (encoding.toLowerCase() === 'gzip') {
50
+ return {
51
+ body: await (0, compression_1.gunzip)(bytes, this.options.maxResponseBodySize),
52
+ headers: stripEncodingHeaders(headers),
53
+ };
54
+ }
55
+ if (encoding.toLowerCase() === 'deflate') {
56
+ return {
57
+ body: await (0, compression_1.inflateDeflate)(bytes, this.options.maxResponseBodySize),
58
+ headers: stripEncodingHeaders(headers),
59
+ };
60
+ }
61
+ return { body: bytes, headers };
62
+ }
63
+ static collectHeaders(headers) {
64
+ const result = {};
65
+ for (const [name, value] of Object.entries(headers)) {
66
+ if (value === undefined) {
67
+ continue;
68
+ }
69
+ result[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
70
+ }
71
+ return result;
72
+ }
73
+ }
74
+ exports.ResponseBodyReader = ResponseBodyReader;
75
+ // After decoding, drop Content-Encoding and the now-stale Content-Length (it described the
76
+ // compressed body, not the decoded one).
77
+ function stripEncodingHeaders(headers) {
78
+ const copy = {};
79
+ for (const [key, value] of Object.entries(headers)) {
80
+ const lower = key.toLowerCase();
81
+ if (lower !== 'content-encoding' && lower !== 'content-length') {
82
+ copy[key] = value;
83
+ }
84
+ }
85
+ return copy;
86
+ }
87
+ function exceededBodySize() {
88
+ return new framework_1.ZLinkFrameworkException(framework_1.ZLinkFrameworkErrorKind.Unavailable, 'HTTP response exceeded the maximum body size');
89
+ }