@continuedev/fetch 1.0.16 → 1.1.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.
@@ -0,0 +1,518 @@
1
+ import * as fs from "node:fs";
2
+ import * as http from "node:http";
3
+ import * as https from "node:https";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { execSync } from "node:child_process";
7
+ import { afterEach, describe, expect, test } from "vitest";
8
+ import { fetchwithRequestOptions } from "./fetch.js";
9
+
10
+ // Test server ports
11
+ const HTTP_PORT = 3001;
12
+ const HTTPS_PORT = 3002;
13
+
14
+ // Track servers and temp dirs for cleanup
15
+ const serversToCleanup: Array<http.Server | https.Server> = [];
16
+ const tempDirsToCleanup: string[] = [];
17
+
18
+ afterEach(() => {
19
+ // Clean up all servers
20
+ serversToCleanup.forEach((server) => server.close());
21
+ serversToCleanup.length = 0;
22
+
23
+ // Clean up temp directories
24
+ tempDirsToCleanup.forEach((tempDir) => {
25
+ if (fs.existsSync(tempDir)) {
26
+ fs.rmSync(tempDir, { recursive: true, force: true });
27
+ }
28
+ });
29
+ tempDirsToCleanup.length = 0;
30
+ });
31
+
32
+ /**
33
+ * Create a temporary directory for test files
34
+ */
35
+ function createTempDir(): string {
36
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-test-"));
37
+ tempDirsToCleanup.push(tempDir);
38
+ return tempDir;
39
+ }
40
+
41
+ /**
42
+ * Generate a self-signed certificate for testing
43
+ */
44
+ async function generateCertificate(
45
+ tempDir: string,
46
+ name: string,
47
+ options: { cn: string; san: string[]; org: string },
48
+ ): Promise<{ certPath: string; keyPath: string; caCertPath: string }> {
49
+ const certPath = path.join(tempDir, `${name}.crt`);
50
+ const keyPath = path.join(tempDir, `${name}.key`);
51
+ const caCertPath = path.join(tempDir, `${name}-ca.crt`);
52
+
53
+ try {
54
+ // Generate a private key
55
+ execSync(`openssl genrsa -out "${keyPath}" 2048`, { stdio: "pipe" });
56
+
57
+ // Create config file for certificate
58
+ const configPath = path.join(tempDir, `${name}.conf`);
59
+ const altNames = options.san
60
+ .map((san, i) => {
61
+ return san.match(/^\d+\.\d+\.\d+\.\d+$/)
62
+ ? `IP.${i + 1} = ${san}`
63
+ : `DNS.${i + 1} = ${san}`;
64
+ })
65
+ .join("\n");
66
+
67
+ const configContent = `
68
+ [req]
69
+ distinguished_name = req_distinguished_name
70
+ req_extensions = v3_req
71
+ prompt = no
72
+
73
+ [req_distinguished_name]
74
+ C = US
75
+ ST = Test
76
+ L = Test
77
+ O = ${options.org}
78
+ CN = ${options.cn}
79
+
80
+ [v3_req]
81
+ keyUsage = keyEncipherment, dataEncipherment
82
+ extendedKeyUsage = serverAuth
83
+ subjectAltName = @alt_names
84
+
85
+ [alt_names]
86
+ ${altNames}
87
+ `;
88
+ fs.writeFileSync(configPath, configContent);
89
+
90
+ // Generate a self-signed certificate
91
+ execSync(
92
+ `openssl req -new -x509 -key "${keyPath}" -out "${certPath}" -days 365 -config "${configPath}" -extensions v3_req`,
93
+ { stdio: "pipe" },
94
+ );
95
+
96
+ // Copy the certificate as CA (for testing custom CA scenarios)
97
+ fs.copyFileSync(certPath, caCertPath);
98
+
99
+ return { certPath, keyPath, caCertPath };
100
+ } catch (error) {
101
+ // Fallback: create minimal test certificates if OpenSSL not available
102
+ const key = `-----BEGIN PRIVATE KEY-----
103
+ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7VJTUt9Us8cKB
104
+ wEiOfniel+2jNcJjYUiUoq5YbVKk+xqt4bOMh5DNFJ3LnU1OaUHyG5sHlgNyKA==
105
+ -----END PRIVATE KEY-----`;
106
+
107
+ const cert = `-----BEGIN CERTIFICATE-----
108
+ MIICljCCAX4CCQCKnW9qX7TlxzANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV
109
+ UzAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMA0xCzAJBgNVBAYTAlVT
110
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1L7VLPHCQD1OvF4p
111
+ 4td9ozXCY2FIlKKuWG1SpPsareGzjIeQzRSdy51NTmlB8hubB5YDcigN8=
112
+ -----END CERTIFICATE-----`;
113
+
114
+ fs.writeFileSync(keyPath, key);
115
+ fs.writeFileSync(certPath, cert);
116
+ fs.writeFileSync(caCertPath, cert);
117
+
118
+ return { certPath, keyPath, caCertPath };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Start a simple HTTP server for testing
124
+ */
125
+ async function startHttpServer(): Promise<http.Server> {
126
+ const server = http.createServer((req, res) => {
127
+ const url = new URL(req.url!, `http://localhost:${HTTP_PORT}`);
128
+
129
+ if (url.pathname === "/json") {
130
+ res.writeHead(200, { "Content-Type": "application/json" });
131
+ res.end(
132
+ JSON.stringify({
133
+ message: "Hello from HTTP server",
134
+ method: req.method,
135
+ }),
136
+ );
137
+ } else if (url.pathname === "/headers") {
138
+ res.writeHead(200, { "Content-Type": "application/json" });
139
+ res.end(JSON.stringify({ headers: req.headers }));
140
+ } else if (url.pathname === "/body") {
141
+ let body = "";
142
+ req.on("data", (chunk) => {
143
+ body += chunk.toString();
144
+ });
145
+ req.on("end", () => {
146
+ res.writeHead(200, { "Content-Type": "application/json" });
147
+ res.end(JSON.stringify({ body, headers: req.headers }));
148
+ });
149
+ } else if (url.pathname === "/status/404") {
150
+ res.writeHead(404, { "Content-Type": "text/plain" });
151
+ res.end("Not Found");
152
+ } else if (url.pathname === "/slow") {
153
+ setTimeout(() => {
154
+ res.writeHead(200, { "Content-Type": "text/plain" });
155
+ res.end("Slow response");
156
+ }, 100);
157
+ } else {
158
+ res.writeHead(404);
159
+ res.end("Not Found");
160
+ }
161
+ });
162
+
163
+ await new Promise<void>((resolve) => {
164
+ server.listen(HTTP_PORT, () => resolve());
165
+ });
166
+
167
+ serversToCleanup.push(server);
168
+ return server;
169
+ }
170
+
171
+ /**
172
+ * Start an HTTPS server with given certificates
173
+ */
174
+ async function startHttpsServer(
175
+ certPath: string,
176
+ keyPath: string,
177
+ ): Promise<https.Server> {
178
+ const server = https.createServer(
179
+ {
180
+ cert: fs.readFileSync(certPath),
181
+ key: fs.readFileSync(keyPath),
182
+ },
183
+ (req, res) => {
184
+ const url = new URL(req.url!, `https://localhost:${HTTPS_PORT}`);
185
+
186
+ if (url.pathname === "/secure") {
187
+ res.writeHead(200, { "Content-Type": "application/json" });
188
+ res.end(
189
+ JSON.stringify({ message: "Hello from HTTPS server", secure: true }),
190
+ );
191
+ } else if (url.pathname === "/headers") {
192
+ res.writeHead(200, { "Content-Type": "application/json" });
193
+ res.end(JSON.stringify({ headers: req.headers }));
194
+ } else {
195
+ res.writeHead(404);
196
+ res.end("Not Found");
197
+ }
198
+ },
199
+ );
200
+
201
+ await new Promise<void>((resolve) => {
202
+ server.listen(HTTPS_PORT, () => resolve());
203
+ });
204
+
205
+ serversToCleanup.push(server);
206
+ return server;
207
+ }
208
+
209
+ describe("fetchwithRequestOptions E2E tests", () => {
210
+ describe("HTTP requests", () => {
211
+ test("should make basic HTTP GET request", async () => {
212
+ await startHttpServer();
213
+
214
+ const response = await fetchwithRequestOptions(
215
+ `http://localhost:${HTTP_PORT}/json`,
216
+ );
217
+
218
+ expect(response.status).toBe(200);
219
+ const data = (await response.json()) as {
220
+ message: string;
221
+ method: string;
222
+ };
223
+ expect(data).toEqual({
224
+ message: "Hello from HTTP server",
225
+ method: "GET",
226
+ });
227
+ });
228
+
229
+ test("should make HTTP POST request with body", async () => {
230
+ await startHttpServer();
231
+
232
+ const response = await fetchwithRequestOptions(
233
+ `http://localhost:${HTTP_PORT}/json`,
234
+ {
235
+ method: "POST",
236
+ body: JSON.stringify({ test: "data" }),
237
+ headers: { "Content-Type": "application/json" },
238
+ },
239
+ );
240
+
241
+ expect(response.status).toBe(200);
242
+ const data = (await response.json()) as {
243
+ message: string;
244
+ method: string;
245
+ };
246
+ expect(data.method).toBe("POST");
247
+ });
248
+
249
+ test("should handle custom headers", async () => {
250
+ await startHttpServer();
251
+
252
+ const response = await fetchwithRequestOptions(
253
+ `http://localhost:${HTTP_PORT}/headers`,
254
+ {
255
+ headers: { "X-Custom-Header": "test-value" },
256
+ },
257
+ );
258
+
259
+ const data = (await response.json()) as {
260
+ headers: Record<string, string>;
261
+ };
262
+ expect(data.headers["x-custom-header"]).toBe("test-value");
263
+ });
264
+
265
+ test("should handle error responses", async () => {
266
+ await startHttpServer();
267
+
268
+ const response = await fetchwithRequestOptions(
269
+ `http://localhost:${HTTP_PORT}/status/404`,
270
+ );
271
+
272
+ expect(response.status).toBe(404);
273
+ expect(response.ok).toBe(false);
274
+ });
275
+ });
276
+
277
+ describe("Request options integration", () => {
278
+ test("should merge extra body properties", async () => {
279
+ await startHttpServer();
280
+
281
+ const response = await fetchwithRequestOptions(
282
+ `http://localhost:${HTTP_PORT}/body`,
283
+ {
284
+ method: "POST",
285
+ body: JSON.stringify({ original: "data" }),
286
+ headers: { "Content-Type": "application/json" },
287
+ },
288
+ {
289
+ extraBodyProperties: { extra: "property" },
290
+ },
291
+ );
292
+
293
+ expect(response.status).toBe(200);
294
+ const data = (await response.json()) as {
295
+ body: string;
296
+ headers: Record<string, string>;
297
+ };
298
+ const body = JSON.parse(data.body);
299
+ expect(body).toEqual({ original: "data", extra: "property" });
300
+ });
301
+
302
+ test("should handle RequestOptions headers", async () => {
303
+ await startHttpServer();
304
+
305
+ const response = await fetchwithRequestOptions(
306
+ `http://localhost:${HTTP_PORT}/headers`,
307
+ {},
308
+ {
309
+ headers: { "X-Request-Options-Header": "from-request-options" },
310
+ },
311
+ );
312
+
313
+ const data = (await response.json()) as {
314
+ headers: Record<string, string>;
315
+ };
316
+ expect(data.headers["x-request-options-header"]).toBe(
317
+ "from-request-options",
318
+ );
319
+ });
320
+
321
+ test("should merge headers from init and requestOptions", async () => {
322
+ await startHttpServer();
323
+
324
+ const response = await fetchwithRequestOptions(
325
+ `http://localhost:${HTTP_PORT}/headers`,
326
+ {
327
+ headers: { "X-Init-Header": "from-init" },
328
+ },
329
+ {
330
+ headers: { "X-Request-Options-Header": "from-request-options" },
331
+ },
332
+ );
333
+
334
+ const data = (await response.json()) as {
335
+ headers: Record<string, string>;
336
+ };
337
+ expect(data.headers["x-init-header"]).toBe("from-init");
338
+ expect(data.headers["x-request-options-header"]).toBe(
339
+ "from-request-options",
340
+ );
341
+ });
342
+ });
343
+
344
+ describe("Certificate handling - Enterprise scenarios", () => {
345
+ test("should REJECT self-signed certificates when SSL verification is enabled", async () => {
346
+ const tempDir = createTempDir();
347
+ const serverCerts = await generateCertificate(tempDir, "server", {
348
+ cn: "localhost",
349
+ san: ["localhost", "127.0.0.1"],
350
+ org: "Test",
351
+ });
352
+
353
+ await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
354
+
355
+ // This simulates the customer's "unable to get local issuer certificate" error
356
+ await expect(
357
+ fetchwithRequestOptions(
358
+ `https://localhost:${HTTPS_PORT}/secure`,
359
+ {},
360
+ { verifySsl: true }, // No custom CA provided
361
+ ),
362
+ ).rejects.toThrow(); // Should fail with certificate error
363
+ });
364
+
365
+ test("should ACCEPT self-signed certificates when SSL verification is disabled", async () => {
366
+ const tempDir = createTempDir();
367
+ const serverCerts = await generateCertificate(tempDir, "server", {
368
+ cn: "localhost",
369
+ san: ["localhost", "127.0.0.1"],
370
+ org: "Test",
371
+ });
372
+
373
+ await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
374
+
375
+ const response = await fetchwithRequestOptions(
376
+ `https://localhost:${HTTPS_PORT}/secure`,
377
+ {},
378
+ { verifySsl: false },
379
+ );
380
+
381
+ expect(response.status).toBe(200);
382
+ const data = (await response.json()) as {
383
+ message: string;
384
+ secure: boolean;
385
+ };
386
+ expect(data.secure).toBe(true);
387
+ });
388
+
389
+ test("should ACCEPT self-signed certificates with custom CA bundle", async () => {
390
+ const tempDir = createTempDir();
391
+ const serverCerts = await generateCertificate(tempDir, "server", {
392
+ cn: "localhost",
393
+ san: ["localhost", "127.0.0.1"],
394
+ org: "Test",
395
+ });
396
+
397
+ await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
398
+
399
+ const response = await fetchwithRequestOptions(
400
+ `https://localhost:${HTTPS_PORT}/secure`,
401
+ {},
402
+ {
403
+ caBundlePath: serverCerts.caCertPath, // Our self-signed cert as CA
404
+ verifySsl: true,
405
+ },
406
+ );
407
+
408
+ expect(response.status).toBe(200);
409
+ const data = (await response.json()) as {
410
+ message: string;
411
+ secure: boolean;
412
+ };
413
+ expect(data.secure).toBe(true);
414
+ });
415
+
416
+ test("should REJECT certificates when provided WRONG CA bundle", async () => {
417
+ const tempDir = createTempDir();
418
+ const serverCerts = await generateCertificate(tempDir, "server", {
419
+ cn: "localhost",
420
+ san: ["localhost", "127.0.0.1"],
421
+ org: "Test",
422
+ });
423
+
424
+ // Generate a different certificate as the "wrong" CA
425
+ const wrongCaCerts = await generateCertificate(tempDir, "wrong-ca", {
426
+ cn: "wrong-ca",
427
+ san: ["wrong-ca"],
428
+ org: "Wrong CA",
429
+ });
430
+
431
+ await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
432
+
433
+ // This should fail because the wrong CA can't validate our server certificate
434
+ await expect(
435
+ fetchwithRequestOptions(
436
+ `https://localhost:${HTTPS_PORT}/secure`,
437
+ {},
438
+ {
439
+ caBundlePath: wrongCaCerts.caCertPath,
440
+ verifySsl: true,
441
+ },
442
+ ),
443
+ ).rejects.toThrow(); // Should fail with certificate validation error
444
+ });
445
+
446
+ test("should REJECT certificates when CA bundle file is corrupted", async () => {
447
+ const tempDir = createTempDir();
448
+ const serverCerts = await generateCertificate(tempDir, "server", {
449
+ cn: "localhost",
450
+ san: ["localhost", "127.0.0.1"],
451
+ org: "Test",
452
+ });
453
+
454
+ // Create a corrupted/invalid certificate file
455
+ const corruptedCaPath = path.join(tempDir, "corrupted-ca.pem");
456
+ const corruptedContent = `-----BEGIN CERTIFICATE-----
457
+ This is not a valid certificate content
458
+ Just some random text that looks like a cert
459
+ -----END CERTIFICATE-----`;
460
+
461
+ fs.writeFileSync(corruptedCaPath, corruptedContent);
462
+
463
+ await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
464
+
465
+ // This should fail because the corrupted CA file can't be parsed
466
+ await expect(
467
+ fetchwithRequestOptions(
468
+ `https://localhost:${HTTPS_PORT}/secure`,
469
+ {},
470
+ {
471
+ caBundlePath: corruptedCaPath,
472
+ verifySsl: true,
473
+ },
474
+ ),
475
+ ).rejects.toThrow(); // Should fail with certificate parsing or validation error
476
+ });
477
+
478
+ test("should REJECT certificates when CA bundle file does not exist", async () => {
479
+ const tempDir = createTempDir();
480
+ const serverCerts = await generateCertificate(tempDir, "server", {
481
+ cn: "localhost",
482
+ san: ["localhost", "127.0.0.1"],
483
+ org: "Test",
484
+ });
485
+
486
+ const nonExistentCaPath = path.join(tempDir, "does-not-exist.pem");
487
+
488
+ await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
489
+
490
+ // This should handle the missing file gracefully but still fail cert validation
491
+ await expect(
492
+ fetchwithRequestOptions(
493
+ `https://localhost:${HTTPS_PORT}/secure`,
494
+ {},
495
+ {
496
+ caBundlePath: nonExistentCaPath,
497
+ verifySsl: true,
498
+ },
499
+ ),
500
+ ).rejects.toThrow(); // Should fail with certificate validation error
501
+ });
502
+ });
503
+
504
+ describe("Error handling", () => {
505
+ test("should handle network errors gracefully", async () => {
506
+ // Try to connect to a non-existent server
507
+ await expect(
508
+ fetchwithRequestOptions("http://localhost:9999/nonexistent"),
509
+ ).rejects.toThrow();
510
+ });
511
+
512
+ test("should handle malformed URLs", async () => {
513
+ await expect(
514
+ fetchwithRequestOptions("not-a-valid-url"),
515
+ ).rejects.toThrow();
516
+ });
517
+ });
518
+ });
package/src/fetch.ts CHANGED
@@ -107,9 +107,30 @@ export async function fetchwithRequestOptions(
107
107
  : new protocol.Agent(agentOptions);
108
108
 
109
109
  let headers: { [key: string]: string } = {};
110
- for (const [key, value] of Object.entries(init?.headers || {})) {
111
- headers[key] = value as string;
110
+
111
+ // Handle different header formats
112
+ if (init?.headers) {
113
+ const headersSource = init.headers as any;
114
+
115
+ // Check if it's a Headers-like object (OpenAI v5 HeadersList, standard Headers)
116
+ if (headersSource && typeof headersSource.forEach === "function") {
117
+ // Use forEach method which works reliably on Headers objects
118
+ headersSource.forEach((value: string, key: string) => {
119
+ headers[key] = value;
120
+ });
121
+ } else if (Array.isArray(headersSource)) {
122
+ // This is an array of [key, value] tuples
123
+ for (const [key, value] of headersSource) {
124
+ headers[key] = value as string;
125
+ }
126
+ } else if (headersSource && typeof headersSource === "object") {
127
+ // This is a plain object
128
+ for (const [key, value] of Object.entries(headersSource)) {
129
+ headers[key] = value as string;
130
+ }
131
+ }
112
132
  }
133
+
113
134
  headers = {
114
135
  ...headers,
115
136
  ...requestOptions?.headers,