@telorun/http-client 0.1.1 → 0.1.3
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.
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import { Readable } from "stream";
|
|
2
3
|
interface TeloResponse {
|
|
3
4
|
status: number;
|
|
4
5
|
headers: Record<string, string>;
|
|
5
6
|
body: unknown;
|
|
6
7
|
}
|
|
7
|
-
interface
|
|
8
|
-
client?: string;
|
|
8
|
+
interface HttpRequestInputs {
|
|
9
9
|
url: string;
|
|
10
10
|
method?: string;
|
|
11
11
|
query?: Record<string, string>;
|
|
12
12
|
headers?: Record<string, string>;
|
|
13
13
|
body?: string | Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
interface HttpRequestManifest extends HttpRequestInputs {
|
|
16
|
+
client?: string;
|
|
14
17
|
timeout?: number;
|
|
15
18
|
throwOnHttpError?: boolean;
|
|
16
19
|
retries?: number;
|
|
20
|
+
mode?: "buffer" | "stream";
|
|
21
|
+
inputs?: HttpRequestInputs;
|
|
17
22
|
}
|
|
18
23
|
declare class HttpRequestResource implements ResourceInstance {
|
|
19
24
|
private readonly manifest;
|
|
20
25
|
private readonly ctx;
|
|
21
26
|
constructor(manifest: HttpRequestManifest, ctx: ResourceContext);
|
|
22
|
-
invoke(input: any): Promise<TeloResponse>;
|
|
27
|
+
invoke(input: any): Promise<TeloResponse | Readable>;
|
|
23
28
|
}
|
|
24
29
|
export declare function register(): void;
|
|
25
30
|
export declare function create(resource: HttpRequestManifest, ctx: ResourceContext): Promise<HttpRequestResource>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PassThrough } from "stream";
|
|
1
2
|
const MAX_REDIRECTS = 5;
|
|
2
3
|
const DEFAULT_TIMEOUT = 10000;
|
|
3
4
|
function createNetworkError(code, message, url) {
|
|
@@ -37,7 +38,7 @@ function normalizeHeaders(headers) {
|
|
|
37
38
|
}
|
|
38
39
|
return result;
|
|
39
40
|
}
|
|
40
|
-
async function executeRequest(url, method, headers, body, timeout) {
|
|
41
|
+
async function executeRequest(url, method, headers, body, timeout, stream = false) {
|
|
41
42
|
const controller = new AbortController();
|
|
42
43
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
43
44
|
let currentUrl = url;
|
|
@@ -71,6 +72,34 @@ async function executeRequest(url, method, headers, body, timeout) {
|
|
|
71
72
|
response.headers.forEach((value, key) => {
|
|
72
73
|
responseHeaders[key.toLowerCase()] = value;
|
|
73
74
|
});
|
|
75
|
+
// Stream mode: pump body into a PassThrough eagerly so data flows immediately
|
|
76
|
+
if (stream) {
|
|
77
|
+
const webStream = response.body;
|
|
78
|
+
const body = new PassThrough();
|
|
79
|
+
(async () => {
|
|
80
|
+
if (!webStream) {
|
|
81
|
+
body.end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const reader = webStream.getReader();
|
|
85
|
+
try {
|
|
86
|
+
while (true) {
|
|
87
|
+
const { done, value } = await reader.read();
|
|
88
|
+
if (done)
|
|
89
|
+
break;
|
|
90
|
+
body.push(Buffer.from(value));
|
|
91
|
+
}
|
|
92
|
+
body.end();
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
body.destroy(err);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
reader.releaseLock();
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return { status: response.status, headers: responseHeaders, body };
|
|
102
|
+
}
|
|
74
103
|
// Deserialize body
|
|
75
104
|
const contentType = responseHeaders["content-type"] ?? "";
|
|
76
105
|
let responseBody;
|
|
@@ -91,13 +120,13 @@ async function executeRequest(url, method, headers, body, timeout) {
|
|
|
91
120
|
clearTimeout(timeoutId);
|
|
92
121
|
}
|
|
93
122
|
}
|
|
94
|
-
async function executeWithRetry(url, method, headers, body, timeout, retriesLeft) {
|
|
123
|
+
async function executeWithRetry(url, method, headers, body, timeout, retriesLeft, stream = false) {
|
|
95
124
|
try {
|
|
96
|
-
return await executeRequest(url, method, headers, body, timeout);
|
|
125
|
+
return await executeRequest(url, method, headers, body, timeout, stream);
|
|
97
126
|
}
|
|
98
127
|
catch (err) {
|
|
99
128
|
if (retriesLeft > 0 && err.error === "NetworkError") {
|
|
100
|
-
return executeWithRetry(url, method, headers, body, timeout, retriesLeft - 1);
|
|
129
|
+
return executeWithRetry(url, method, headers, body, timeout, retriesLeft - 1, stream);
|
|
101
130
|
}
|
|
102
131
|
throw err;
|
|
103
132
|
}
|
|
@@ -122,16 +151,35 @@ class HttpRequestResource {
|
|
|
122
151
|
if (!client) {
|
|
123
152
|
throw new Error(`Http.Client "${clientName}" not found`);
|
|
124
153
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
const clientConfig = typeof client.snapshot === "function"
|
|
155
|
+
? client.snapshot()
|
|
156
|
+
: client;
|
|
157
|
+
const resolvedBaseUrl = ctx.expandValue(clientConfig.baseUrl ?? "", input ?? {});
|
|
158
|
+
clientBaseUrl = typeof resolvedBaseUrl === "string" ? resolvedBaseUrl : "";
|
|
159
|
+
const resolvedHeaders = ctx.expandValue(clientConfig.headers ?? {}, input ?? {});
|
|
160
|
+
clientHeaders = normalizeHeaders((resolvedHeaders ?? {}));
|
|
161
|
+
const resolvedTimeout = ctx.expandValue(clientConfig.timeout ?? DEFAULT_TIMEOUT, input ?? {});
|
|
162
|
+
clientTimeout =
|
|
163
|
+
typeof resolvedTimeout === "number" && Number.isFinite(resolvedTimeout)
|
|
164
|
+
? resolvedTimeout
|
|
165
|
+
: DEFAULT_TIMEOUT;
|
|
128
166
|
}
|
|
129
|
-
// Expand template fields
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
167
|
+
// Expand template fields from manifest.inputs using runtime input as context
|
|
168
|
+
// Manifest-level fields (url, method, etc.) serve as defaults when inputs is absent
|
|
169
|
+
const manifestInputs = {
|
|
170
|
+
url: m.url,
|
|
171
|
+
method: m.method,
|
|
172
|
+
query: m.query,
|
|
173
|
+
headers: m.headers,
|
|
174
|
+
body: m.body,
|
|
175
|
+
...m.inputs,
|
|
176
|
+
};
|
|
177
|
+
const resolved = ctx.expandValue(manifestInputs, input ?? {});
|
|
178
|
+
const rawUrl = resolved.url;
|
|
179
|
+
const method = ((resolved.method ?? "GET") || "GET").toUpperCase();
|
|
180
|
+
const requestHeaders = normalizeHeaders((resolved.headers ?? {}));
|
|
181
|
+
const query = (resolved.query ?? {});
|
|
182
|
+
const body = resolved.body;
|
|
135
183
|
const effectiveTimeout = m.timeout ?? clientTimeout;
|
|
136
184
|
const retries = m.retries ?? 0;
|
|
137
185
|
const throwOnHttpError = m.throwOnHttpError ?? false;
|
|
@@ -166,10 +214,13 @@ class HttpRequestResource {
|
|
|
166
214
|
serializedBody = String(body);
|
|
167
215
|
}
|
|
168
216
|
}
|
|
169
|
-
const response = await executeWithRetry(fullUrl, method, mergedHeaders, serializedBody, effectiveTimeout, retries);
|
|
217
|
+
const response = await executeWithRetry(fullUrl, method, mergedHeaders, serializedBody, effectiveTimeout, retries, m.mode === "stream");
|
|
170
218
|
if (throwOnHttpError && response.status >= 400) {
|
|
171
219
|
throw new Error(`HTTP ${response.status} error from ${fullUrl}`);
|
|
172
220
|
}
|
|
221
|
+
if (m.mode === "stream") {
|
|
222
|
+
return response.body;
|
|
223
|
+
}
|
|
173
224
|
return response;
|
|
174
225
|
}
|
|
175
226
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./http-client": {
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"dist
|
|
16
|
+
"dist",
|
|
17
|
+
"src/**"
|
|
17
18
|
],
|
|
18
19
|
"dependencies": {
|
|
19
|
-
"@telorun/sdk": "0.2.
|
|
20
|
+
"@telorun/sdk": "0.2.7"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/node": "^20.0.0",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
interface HttpClientManifest {
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
followRedirects?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class HttpClientResource implements ResourceInstance {
|
|
11
|
+
readonly metadata: { name: string; module: string; [key: string]: any };
|
|
12
|
+
|
|
13
|
+
constructor(private readonly manifest: any) {
|
|
14
|
+
this.metadata = manifest.metadata ?? {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
snapshot() {
|
|
18
|
+
return {
|
|
19
|
+
baseUrl: this.manifest.baseUrl ?? "",
|
|
20
|
+
headers: this.manifest.headers ?? {},
|
|
21
|
+
timeout: this.manifest.timeout ?? 10000,
|
|
22
|
+
followRedirects: this.manifest.followRedirects ?? true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function register(): void {}
|
|
28
|
+
|
|
29
|
+
export async function create(
|
|
30
|
+
resource: HttpClientManifest,
|
|
31
|
+
_ctx: ResourceContext,
|
|
32
|
+
): Promise<HttpClientResource> {
|
|
33
|
+
return new HttpClientResource(resource);
|
|
34
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import { PassThrough, Readable } from "stream";
|
|
3
|
+
|
|
4
|
+
const MAX_REDIRECTS = 5;
|
|
5
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
6
|
+
|
|
7
|
+
interface TeloResponse {
|
|
8
|
+
status: number;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface NetworkErrorPayload {
|
|
14
|
+
error: "NetworkError";
|
|
15
|
+
code: "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_RESOLUTION_FAILED" | "SSL_ERROR";
|
|
16
|
+
message: string;
|
|
17
|
+
details: { url: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createNetworkError(
|
|
21
|
+
code: NetworkErrorPayload["code"],
|
|
22
|
+
message: string,
|
|
23
|
+
url: string,
|
|
24
|
+
): Error {
|
|
25
|
+
const payload: NetworkErrorPayload = {
|
|
26
|
+
error: "NetworkError",
|
|
27
|
+
code,
|
|
28
|
+
message,
|
|
29
|
+
details: { url },
|
|
30
|
+
};
|
|
31
|
+
const err = new Error(message);
|
|
32
|
+
(err as any).networkError = payload;
|
|
33
|
+
(err as any).code = code;
|
|
34
|
+
Object.assign(err, payload);
|
|
35
|
+
return err;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mapNetworkError(err: unknown, url: string): never {
|
|
39
|
+
const e = err as Error;
|
|
40
|
+
if (e.name === "AbortError") {
|
|
41
|
+
throw createNetworkError("TIMEOUT", `Request timed out`, url);
|
|
42
|
+
}
|
|
43
|
+
const msg = e.message?.toLowerCase() ?? "";
|
|
44
|
+
if (msg.includes("econnrefused") || msg.includes("connection refused")) {
|
|
45
|
+
throw createNetworkError("CONNECTION_REFUSED", e.message, url);
|
|
46
|
+
}
|
|
47
|
+
if (msg.includes("enotfound") || msg.includes("getaddrinfo") || msg.includes("dns")) {
|
|
48
|
+
throw createNetworkError("DNS_RESOLUTION_FAILED", e.message, url);
|
|
49
|
+
}
|
|
50
|
+
if (msg.includes("ssl") || msg.includes("cert") || msg.includes("tls")) {
|
|
51
|
+
throw createNetworkError("SSL_ERROR", e.message, url);
|
|
52
|
+
}
|
|
53
|
+
throw createNetworkError("CONNECTION_REFUSED", e.message, url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
57
|
+
const result: Record<string, string> = {};
|
|
58
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
59
|
+
result[key.toLowerCase()] = value;
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function executeRequest(
|
|
65
|
+
url: string,
|
|
66
|
+
method: string,
|
|
67
|
+
headers: Record<string, string>,
|
|
68
|
+
body: string | undefined,
|
|
69
|
+
timeout: number,
|
|
70
|
+
stream = false,
|
|
71
|
+
): Promise<TeloResponse> {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
74
|
+
|
|
75
|
+
let currentUrl = url;
|
|
76
|
+
let redirectsLeft = MAX_REDIRECTS;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
const response = await fetch(currentUrl, {
|
|
81
|
+
method,
|
|
82
|
+
headers,
|
|
83
|
+
body,
|
|
84
|
+
redirect: "manual",
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Handle redirects manually (limit to MAX_REDIRECTS)
|
|
89
|
+
if ((response.status === 301 || response.status === 302) && redirectsLeft > 0) {
|
|
90
|
+
const location = response.headers.get("location");
|
|
91
|
+
if (location) {
|
|
92
|
+
currentUrl = location.startsWith("http")
|
|
93
|
+
? location
|
|
94
|
+
: new URL(location, currentUrl).toString();
|
|
95
|
+
redirectsLeft--;
|
|
96
|
+
// For redirects, switch to GET and drop body per HTTP spec
|
|
97
|
+
method = "GET";
|
|
98
|
+
body = undefined;
|
|
99
|
+
delete headers["content-length"];
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Normalize response headers
|
|
105
|
+
const responseHeaders: Record<string, string> = {};
|
|
106
|
+
response.headers.forEach((value, key) => {
|
|
107
|
+
responseHeaders[key.toLowerCase()] = value;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Stream mode: pump body into a PassThrough eagerly so data flows immediately
|
|
111
|
+
if (stream) {
|
|
112
|
+
const webStream = response.body;
|
|
113
|
+
const body = new PassThrough();
|
|
114
|
+
(async () => {
|
|
115
|
+
if (!webStream) {
|
|
116
|
+
body.end();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const reader = webStream.getReader();
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
body.push(Buffer.from(value));
|
|
125
|
+
}
|
|
126
|
+
body.end();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
body.destroy(err as Error);
|
|
129
|
+
} finally {
|
|
130
|
+
reader.releaseLock();
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
return { status: response.status, headers: responseHeaders, body };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Deserialize body
|
|
137
|
+
const contentType = responseHeaders["content-type"] ?? "";
|
|
138
|
+
let responseBody: unknown;
|
|
139
|
+
|
|
140
|
+
if (contentType.includes("application/json")) {
|
|
141
|
+
const text = await response.text();
|
|
142
|
+
responseBody = text.length === 0 ? null : JSON.parse(text);
|
|
143
|
+
} else {
|
|
144
|
+
responseBody = await response.text();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { status: response.status, headers: responseHeaders, body: responseBody };
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
mapNetworkError(err, url);
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeoutId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function executeWithRetry(
|
|
157
|
+
url: string,
|
|
158
|
+
method: string,
|
|
159
|
+
headers: Record<string, string>,
|
|
160
|
+
body: string | undefined,
|
|
161
|
+
timeout: number,
|
|
162
|
+
retriesLeft: number,
|
|
163
|
+
stream = false,
|
|
164
|
+
): Promise<TeloResponse> {
|
|
165
|
+
try {
|
|
166
|
+
return await executeRequest(url, method, headers, body, timeout, stream);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (retriesLeft > 0 && (err as any).error === "NetworkError") {
|
|
169
|
+
return executeWithRetry(url, method, headers, body, timeout, retriesLeft - 1, stream);
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface HttpRequestInputs {
|
|
176
|
+
url: string;
|
|
177
|
+
method?: string;
|
|
178
|
+
query?: Record<string, string>;
|
|
179
|
+
headers?: Record<string, string>;
|
|
180
|
+
body?: string | Record<string, unknown>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface HttpRequestManifest extends HttpRequestInputs {
|
|
184
|
+
client?: string;
|
|
185
|
+
timeout?: number;
|
|
186
|
+
throwOnHttpError?: boolean;
|
|
187
|
+
retries?: number;
|
|
188
|
+
mode?: "buffer" | "stream";
|
|
189
|
+
inputs?: HttpRequestInputs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class HttpRequestResource implements ResourceInstance {
|
|
193
|
+
constructor(
|
|
194
|
+
private readonly manifest: HttpRequestManifest,
|
|
195
|
+
private readonly ctx: ResourceContext,
|
|
196
|
+
) {}
|
|
197
|
+
|
|
198
|
+
async invoke(input: any): Promise<TeloResponse | Readable> {
|
|
199
|
+
const ctx = this.ctx;
|
|
200
|
+
const m = this.manifest;
|
|
201
|
+
|
|
202
|
+
// Resolve client config
|
|
203
|
+
let clientBaseUrl = "";
|
|
204
|
+
let clientHeaders: Record<string, string> = {};
|
|
205
|
+
let clientTimeout = DEFAULT_TIMEOUT;
|
|
206
|
+
|
|
207
|
+
if (m.client) {
|
|
208
|
+
const clientName = ctx.expandValue(m.client, input ?? {}) as string;
|
|
209
|
+
const client: any = ctx.getResourcesByName("Client", clientName);
|
|
210
|
+
if (!client) {
|
|
211
|
+
throw new Error(`Http.Client "${clientName}" not found`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const clientConfig =
|
|
215
|
+
typeof client.snapshot === "function"
|
|
216
|
+
? (client.snapshot() as Record<string, unknown>)
|
|
217
|
+
: client;
|
|
218
|
+
|
|
219
|
+
const resolvedBaseUrl = ctx.expandValue(clientConfig.baseUrl ?? "", input ?? {});
|
|
220
|
+
clientBaseUrl = typeof resolvedBaseUrl === "string" ? resolvedBaseUrl : "";
|
|
221
|
+
|
|
222
|
+
const resolvedHeaders = ctx.expandValue(clientConfig.headers ?? {}, input ?? {});
|
|
223
|
+
clientHeaders = normalizeHeaders((resolvedHeaders ?? {}) as Record<string, string>);
|
|
224
|
+
|
|
225
|
+
const resolvedTimeout = ctx.expandValue(clientConfig.timeout ?? DEFAULT_TIMEOUT, input ?? {});
|
|
226
|
+
clientTimeout =
|
|
227
|
+
typeof resolvedTimeout === "number" && Number.isFinite(resolvedTimeout)
|
|
228
|
+
? resolvedTimeout
|
|
229
|
+
: DEFAULT_TIMEOUT;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Expand template fields from manifest.inputs using runtime input as context
|
|
233
|
+
// Manifest-level fields (url, method, etc.) serve as defaults when inputs is absent
|
|
234
|
+
const manifestInputs: HttpRequestInputs = {
|
|
235
|
+
url: m.url,
|
|
236
|
+
method: m.method,
|
|
237
|
+
query: m.query,
|
|
238
|
+
headers: m.headers,
|
|
239
|
+
body: m.body,
|
|
240
|
+
...m.inputs,
|
|
241
|
+
};
|
|
242
|
+
const resolved = ctx.expandValue(manifestInputs, input ?? {}) as HttpRequestInputs;
|
|
243
|
+
const rawUrl = resolved.url as string;
|
|
244
|
+
const method = ((resolved.method ?? "GET") || "GET").toUpperCase();
|
|
245
|
+
const requestHeaders = normalizeHeaders((resolved.headers ?? {}) as Record<string, string>);
|
|
246
|
+
const query = (resolved.query ?? {}) as Record<string, string>;
|
|
247
|
+
const body = resolved.body;
|
|
248
|
+
const effectiveTimeout = m.timeout ?? clientTimeout;
|
|
249
|
+
const retries = m.retries ?? 0;
|
|
250
|
+
const throwOnHttpError = m.throwOnHttpError ?? false;
|
|
251
|
+
|
|
252
|
+
// Build URL
|
|
253
|
+
let fullUrl = rawUrl.startsWith("http") ? rawUrl : `${clientBaseUrl}${rawUrl}`;
|
|
254
|
+
|
|
255
|
+
// Append query params
|
|
256
|
+
const queryEntries = Object.entries(query);
|
|
257
|
+
if (queryEntries.length > 0) {
|
|
258
|
+
const params = new URLSearchParams(queryEntries);
|
|
259
|
+
fullUrl = `${fullUrl}${fullUrl.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Merge headers: client defaults < request-specific
|
|
263
|
+
const mergedHeaders: Record<string, string> = { ...clientHeaders, ...requestHeaders };
|
|
264
|
+
|
|
265
|
+
// Serialize body
|
|
266
|
+
let serializedBody: string | undefined;
|
|
267
|
+
if (body !== undefined) {
|
|
268
|
+
if (typeof body === "object" && body !== null) {
|
|
269
|
+
const contentType = mergedHeaders["content-type"] ?? "application/json";
|
|
270
|
+
if (!mergedHeaders["content-type"]) {
|
|
271
|
+
mergedHeaders["content-type"] = "application/json";
|
|
272
|
+
}
|
|
273
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
274
|
+
serializedBody = new URLSearchParams(body as Record<string, string>).toString();
|
|
275
|
+
} else {
|
|
276
|
+
// Default to JSON
|
|
277
|
+
mergedHeaders["content-type"] = mergedHeaders["content-type"] ?? "application/json";
|
|
278
|
+
serializedBody = JSON.stringify(body);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
serializedBody = String(body);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const response = await executeWithRetry(
|
|
286
|
+
fullUrl,
|
|
287
|
+
method,
|
|
288
|
+
mergedHeaders,
|
|
289
|
+
serializedBody,
|
|
290
|
+
effectiveTimeout,
|
|
291
|
+
retries,
|
|
292
|
+
m.mode === "stream",
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (throwOnHttpError && response.status >= 400) {
|
|
296
|
+
throw new Error(`HTTP ${response.status} error from ${fullUrl}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (m.mode === "stream") {
|
|
300
|
+
return response.body as Readable;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return response;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function register(): void {}
|
|
308
|
+
|
|
309
|
+
export async function create(
|
|
310
|
+
resource: HttpRequestManifest,
|
|
311
|
+
ctx: ResourceContext,
|
|
312
|
+
): Promise<HttpRequestResource> {
|
|
313
|
+
return new HttpRequestResource(resource, ctx);
|
|
314
|
+
}
|