@continuedev/fetch 1.0.6 → 1.0.10

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.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@continuedev/fetch",
3
- "version": "1.0.6",
3
+ "version": "1.0.10",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "type": "module",
8
8
  "scripts": {
9
- "test": "jest",
9
+ "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
10
10
  "build": "tsc"
11
11
  },
12
12
  "author": "Nate Sesti and Ty Dunn",
@@ -20,6 +20,10 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/follow-redirects": "^1.14.4",
23
- "jest": "^29.7.0"
23
+ "@types/jest": "^29.5.14",
24
+ "cross-env": "^7.0.3",
25
+ "jest": "^29.7.0",
26
+ "ts-jest": "^29.3.4",
27
+ "ts-node": "^10.9.2"
24
28
  }
25
29
  }
@@ -0,0 +1,172 @@
1
+ import { globalAgent } from "https";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { getAgentOptions } from "./getAgentOptions.js";
6
+
7
+ // Store original env
8
+ const originalEnv = process.env;
9
+ const originalGlobalAgentOptions = { ...globalAgent.options };
10
+
11
+ // Temporary directory for test certificate files
12
+ let tempDir: string;
13
+
14
+ beforeEach(() => {
15
+ // Create a temporary directory for test files
16
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-test-"));
17
+
18
+ process.env = { ...originalEnv };
19
+
20
+ // Reset globalAgent for each test
21
+ globalAgent.options = { ...originalGlobalAgentOptions };
22
+ });
23
+
24
+ afterEach(() => {
25
+ process.env = originalEnv;
26
+ globalAgent.options = originalGlobalAgentOptions;
27
+
28
+ // Clean up temporary directory
29
+ try {
30
+ fs.rmSync(tempDir, { recursive: true, force: true });
31
+ } catch (error) {
32
+ console.error(`Failed to remove temp directory: ${error}`);
33
+ }
34
+ });
35
+
36
+ // Helper function to create test certificate files
37
+ function createTestCertFile(filename: string, content: string): string {
38
+ const filePath = path.join(tempDir, filename);
39
+ fs.writeFileSync(filePath, content, "utf8");
40
+ return filePath;
41
+ }
42
+
43
+ test("getAgentOptions returns basic configuration with default values", () => {
44
+ const options = getAgentOptions();
45
+
46
+ // Check default timeout (7200 seconds = 2 hours = 7,200,000 ms)
47
+ expect(options.timeout).toBe(7200000);
48
+ expect(options.sessionTimeout).toBe(7200000);
49
+ expect(options.keepAliveMsecs).toBe(7200000);
50
+ expect(options.keepAlive).toBe(true);
51
+
52
+ // Verify certificates array exists and contains items
53
+ expect(options.ca).toBeInstanceOf(Array);
54
+ expect(options.ca.length).toBeGreaterThan(0);
55
+
56
+ // Verify at least one of the real TLS root certificates is included
57
+ // This assumes there's at least one certificate with "CERTIFICATE" in it
58
+ expect(options.ca.some((cert: any) => cert.includes("CERTIFICATE"))).toBe(
59
+ true,
60
+ );
61
+ });
62
+
63
+ test("getAgentOptions respects custom timeout", () => {
64
+ const customTimeout = 300; // 5 minutes
65
+ const options = getAgentOptions({ timeout: customTimeout });
66
+
67
+ // Check timeout values (300 seconds = 300,000 ms)
68
+ expect(options.timeout).toBe(300000);
69
+ expect(options.sessionTimeout).toBe(300000);
70
+ expect(options.keepAliveMsecs).toBe(300000);
71
+ });
72
+
73
+ test("getAgentOptions uses verifySsl setting", () => {
74
+ // With verifySsl true
75
+ let options = getAgentOptions({ verifySsl: true });
76
+ expect(options.rejectUnauthorized).toBe(true);
77
+
78
+ // With verifySsl false
79
+ options = getAgentOptions({ verifySsl: false });
80
+ expect(options.rejectUnauthorized).toBe(false);
81
+ });
82
+
83
+ test("getAgentOptions incorporates custom CA bundle paths", () => {
84
+ // Create a test CA bundle file
85
+ const caBundleContent =
86
+ "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMcuSp7chAYdMA==\n-----END CERTIFICATE-----";
87
+ const caBundlePath = createTestCertFile("ca-bundle.pem", caBundleContent);
88
+
89
+ // Single string path
90
+ let options = getAgentOptions({ caBundlePath });
91
+
92
+ // Verify that our test certificate is included in the CA list
93
+ expect(options.ca).toContain(caBundleContent);
94
+
95
+ // Create multiple test CA bundle files
96
+ const caContent1 =
97
+ "-----BEGIN CERTIFICATE-----\nABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n-----END CERTIFICATE-----";
98
+ const caContent2 =
99
+ "-----BEGIN CERTIFICATE-----\n0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\n-----END CERTIFICATE-----";
100
+ const caPath1 = createTestCertFile("ca1.pem", caContent1);
101
+ const caPath2 = createTestCertFile("ca2.pem", caContent2);
102
+
103
+ // Array of paths
104
+ options = getAgentOptions({
105
+ caBundlePath: [caPath1, caPath2],
106
+ });
107
+
108
+ // Verify that both test certificates are included in the CA list
109
+ expect(options.ca).toContain(caContent1);
110
+ expect(options.ca).toContain(caContent2);
111
+ });
112
+
113
+ test("getAgentOptions includes global certs when running as binary", () => {
114
+ // Set up test certs in globalAgent
115
+ globalAgent.options.ca = ["global-cert-1", "global-cert-2"];
116
+
117
+ // Set IS_BINARY environment variable
118
+ process.env.IS_BINARY = "true";
119
+
120
+ const options = getAgentOptions();
121
+
122
+ // Test for global certs
123
+ expect(options.ca).toContain("global-cert-1");
124
+ expect(options.ca).toContain("global-cert-2");
125
+ });
126
+
127
+ test("getAgentOptions handles client certificate configuration", () => {
128
+ // Create test certificate files
129
+ const clientCertContent =
130
+ "-----BEGIN CERTIFICATE-----\nCLIENTCERT\n-----END CERTIFICATE-----";
131
+ const clientKeyContent =
132
+ "-----BEGIN PRIVATE KEY-----\nCLIENTKEY\n-----END PRIVATE KEY-----";
133
+ const certPath = createTestCertFile("client.cert", clientCertContent);
134
+ const keyPath = createTestCertFile("client.key", clientKeyContent);
135
+
136
+ const clientCertOptions = {
137
+ clientCertificate: {
138
+ cert: certPath,
139
+ key: keyPath,
140
+ passphrase: "secret-passphrase",
141
+ },
142
+ };
143
+
144
+ const options = getAgentOptions(clientCertOptions);
145
+
146
+ expect(options.cert).toBe(clientCertContent);
147
+ expect(options.key).toBe(clientKeyContent);
148
+ expect(options.passphrase).toBe("secret-passphrase");
149
+ });
150
+
151
+ test("getAgentOptions handles client certificate without passphrase", () => {
152
+ // Create test certificate files
153
+ const clientCertContent =
154
+ "-----BEGIN CERTIFICATE-----\nCLIENTCERT2\n-----END CERTIFICATE-----";
155
+ const clientKeyContent =
156
+ "-----BEGIN PRIVATE KEY-----\nCLIENTKEY2\n-----END PRIVATE KEY-----";
157
+ const certPath = createTestCertFile("client2.cert", clientCertContent);
158
+ const keyPath = createTestCertFile("client2.key", clientKeyContent);
159
+
160
+ const clientCertOptions = {
161
+ clientCertificate: {
162
+ cert: certPath,
163
+ key: keyPath,
164
+ },
165
+ };
166
+
167
+ const options = getAgentOptions(clientCertOptions);
168
+
169
+ expect(options.cert).toBe(clientCertContent);
170
+ expect(options.key).toBe(clientKeyContent);
171
+ expect(options.passphrase).toBeUndefined();
172
+ });
package/src/fetch.ts CHANGED
@@ -1,12 +1,10 @@
1
- import { globalAgent } from "https";
2
- import * as fs from "node:fs";
3
- import tls from "node:tls";
4
-
5
1
  import { RequestOptions } from "@continuedev/config-types";
6
2
  import * as followRedirects from "follow-redirects";
7
3
  import { HttpProxyAgent } from "http-proxy-agent";
8
4
  import { HttpsProxyAgent } from "https-proxy-agent";
9
5
  import fetch, { RequestInit, Response } from "node-fetch";
6
+ import { getAgentOptions } from "./getAgentOptions.js";
7
+ import { getProxyFromEnv, shouldBypassProxy } from "./util.js";
10
8
 
11
9
  const { http, https } = (followRedirects as any).default;
12
10
 
@@ -20,59 +18,23 @@ export async function fetchwithRequestOptions(
20
18
  url.host = "127.0.0.1";
21
19
  }
22
20
 
23
- const TIMEOUT = 7200; // 7200 seconds = 2 hours
21
+ const agentOptions = getAgentOptions(requestOptions);
24
22
 
25
- let globalCerts: string[] = [];
26
- if (process.env.IS_BINARY) {
27
- if (Array.isArray(globalAgent.options.ca)) {
28
- globalCerts = [...globalAgent.options.ca.map((cert) => cert.toString())];
29
- } else if (typeof globalAgent.options.ca !== "undefined") {
30
- globalCerts.push(globalAgent.options.ca.toString());
31
- }
32
- }
33
- const ca = Array.from(new Set([...tls.rootCertificates, ...globalCerts]));
34
- const customCerts =
35
- typeof requestOptions?.caBundlePath === "string"
36
- ? [requestOptions?.caBundlePath]
37
- : requestOptions?.caBundlePath;
38
- if (customCerts) {
39
- ca.push(
40
- ...customCerts.map((customCert) => fs.readFileSync(customCert, "utf8")),
41
- );
23
+ // Get proxy from options or environment variables
24
+ let proxy = requestOptions?.proxy;
25
+ if (!proxy) {
26
+ proxy = getProxyFromEnv(url.protocol);
42
27
  }
43
28
 
44
- const timeout = (requestOptions?.timeout ?? TIMEOUT) * 1000; // measured in ms
45
-
46
- const agentOptions: { [key: string]: any } = {
47
- ca,
48
- rejectUnauthorized: requestOptions?.verifySsl,
49
- timeout,
50
- sessionTimeout: timeout,
51
- keepAlive: true,
52
- keepAliveMsecs: timeout,
53
- };
54
-
55
- // Handle ClientCertificateOptions
56
- if (requestOptions?.clientCertificate) {
57
- agentOptions.cert = fs.readFileSync(
58
- requestOptions.clientCertificate.cert,
59
- "utf8",
60
- );
61
- agentOptions.key = fs.readFileSync(
62
- requestOptions.clientCertificate.key,
63
- "utf8",
64
- );
65
- if (requestOptions.clientCertificate.passphrase) {
66
- agentOptions.passphrase = requestOptions.clientCertificate.passphrase;
67
- }
68
- }
69
-
70
- const proxy = requestOptions?.proxy;
29
+ // Check if should bypass proxy based on requestOptions or NO_PROXY env var
30
+ const shouldBypass =
31
+ requestOptions?.noProxy?.includes(url.hostname) ||
32
+ shouldBypassProxy(url.hostname);
71
33
 
72
34
  // Create agent
73
35
  const protocol = url.protocol === "https:" ? https : http;
74
36
  const agent =
75
- proxy && !requestOptions?.noProxy?.includes(url.hostname)
37
+ proxy && !shouldBypass
76
38
  ? protocol === https
77
39
  ? new HttpsProxyAgent(proxy, agentOptions)
78
40
  : new HttpProxyAgent(proxy, agentOptions)
@@ -107,19 +69,30 @@ export async function fetchwithRequestOptions(
107
69
  }
108
70
 
109
71
  // fetch the request with the provided options
110
- const resp = await fetch(url, {
111
- ...init,
112
- body: updatedBody ?? init?.body,
113
- headers: headers,
114
- agent: agent,
115
- });
72
+ try {
73
+ const resp = await fetch(url, {
74
+ ...init,
75
+ body: updatedBody ?? init?.body,
76
+ headers: headers,
77
+ agent: agent,
78
+ });
79
+
80
+ if (!resp.ok) {
81
+ const requestId = resp.headers.get("x-request-id");
82
+ if (requestId) {
83
+ console.log(`Request ID: ${requestId}, Status: ${resp.status}`);
84
+ }
85
+ }
116
86
 
117
- if (!resp.ok) {
118
- const requestId = resp.headers.get("x-request-id");
119
- if (requestId) {
120
- console.log(`Request ID: ${requestId}, Status: ${resp.status}`);
87
+ return resp;
88
+ } catch (error) {
89
+ if (error instanceof Error && error.name === "AbortError") {
90
+ // Return a Response object that streamResponse etc can handle
91
+ return new Response(null, {
92
+ status: 499, // Client Closed Request
93
+ statusText: "Client Closed Request",
94
+ });
121
95
  }
96
+ throw error;
122
97
  }
123
-
124
- return resp;
125
98
  }
@@ -0,0 +1,62 @@
1
+ import { globalAgent } from "https";
2
+ import * as fs from "node:fs";
3
+ import tls from "node:tls";
4
+
5
+ import { RequestOptions } from "@continuedev/config-types";
6
+
7
+ /**
8
+ * Prepares agent options based on request options and certificates
9
+ */
10
+ export function getAgentOptions(requestOptions?: RequestOptions): {
11
+ [key: string]: any;
12
+ } {
13
+ const TIMEOUT = 7200; // 7200 seconds = 2 hours
14
+ const timeout = (requestOptions?.timeout ?? TIMEOUT) * 1000; // measured in ms
15
+
16
+ // Get root certificates
17
+ let globalCerts: string[] = [];
18
+ if (process.env.IS_BINARY) {
19
+ if (Array.isArray(globalAgent.options.ca)) {
20
+ globalCerts = [...globalAgent.options.ca.map((cert) => cert.toString())];
21
+ } else if (typeof globalAgent.options.ca !== "undefined") {
22
+ globalCerts.push(globalAgent.options.ca.toString());
23
+ }
24
+ }
25
+
26
+ const ca = Array.from(new Set([...tls.rootCertificates, ...globalCerts]));
27
+ const customCerts =
28
+ typeof requestOptions?.caBundlePath === "string"
29
+ ? [requestOptions?.caBundlePath]
30
+ : requestOptions?.caBundlePath;
31
+ if (customCerts) {
32
+ ca.push(
33
+ ...customCerts.map((customCert) => fs.readFileSync(customCert, "utf8")),
34
+ );
35
+ }
36
+
37
+ const agentOptions: { [key: string]: any } = {
38
+ ca,
39
+ rejectUnauthorized: requestOptions?.verifySsl,
40
+ timeout,
41
+ sessionTimeout: timeout,
42
+ keepAlive: true,
43
+ keepAliveMsecs: timeout,
44
+ };
45
+
46
+ // Handle ClientCertificateOptions
47
+ if (requestOptions?.clientCertificate) {
48
+ agentOptions.cert = fs.readFileSync(
49
+ requestOptions.clientCertificate.cert,
50
+ "utf8",
51
+ );
52
+ agentOptions.key = fs.readFileSync(
53
+ requestOptions.clientCertificate.key,
54
+ "utf8",
55
+ );
56
+ if (requestOptions.clientCertificate.passphrase) {
57
+ agentOptions.passphrase = requestOptions.clientCertificate.passphrase;
58
+ }
59
+ }
60
+
61
+ return agentOptions;
62
+ }
@@ -0,0 +1,109 @@
1
+ import { Readable } from "stream";
2
+ import { parseDataLine, streamSse } from "./stream.js";
3
+
4
+ function createMockResponse(sseLines: string[]): Response {
5
+ // Create a Readable stream that emits the SSE lines
6
+ const stream = new Readable({
7
+ read() {
8
+ for (const line of sseLines) {
9
+ this.push(line + "\n\n");
10
+ }
11
+ this.push(null); // End of stream
12
+ },
13
+ }) as any;
14
+
15
+ // Minimal Response mock
16
+ return {
17
+ status: 200,
18
+ body: stream,
19
+ text: async () => "",
20
+ } as unknown as Response;
21
+ }
22
+
23
+ describe("streamSse", () => {
24
+ it("yields parsed SSE data objects that ends with `data:[DONE]`", async () => {
25
+ const sseLines = [
26
+ 'data: {"foo": "bar"}',
27
+ 'data: {"baz": 42}',
28
+ "data:[DONE]",
29
+ ];
30
+ const response = createMockResponse(sseLines);
31
+
32
+ const results = [];
33
+ for await (const data of streamSse(response)) {
34
+ results.push(data);
35
+ }
36
+
37
+ expect(results).toEqual([{ foo: "bar" }, { baz: 42 }]);
38
+ });
39
+
40
+ it("yields parsed SSE data objects that ends with `data: [DONE]` (with a space before [DONE]", async () => {
41
+ const sseLines = [
42
+ 'data: {"foo": "bar"}',
43
+ 'data: {"baz": 42}',
44
+ "data: [DONE]",
45
+ ];
46
+ const response = createMockResponse(sseLines);
47
+
48
+ const results = [];
49
+ for await (const data of streamSse(response)) {
50
+ results.push(data);
51
+ }
52
+
53
+ expect(results).toEqual([{ foo: "bar" }, { baz: 42 }]);
54
+ });
55
+
56
+ it("throws on malformed JSON", async () => {
57
+ const sseLines = ['data: {"foo": "bar"', "data:[DONE]"];
58
+ const response = createMockResponse(sseLines);
59
+
60
+ const iterator = streamSse(response)[Symbol.asyncIterator]();
61
+ await expect(iterator.next()).rejects.toThrow(/Malformed JSON/);
62
+ });
63
+ });
64
+
65
+ describe("parseDataLine", () => {
66
+ test("parseDataLine should parse valid JSON data with 'data: ' prefix", () => {
67
+ const line = 'data: {"message":"hello","status":"ok"}';
68
+ const result = parseDataLine(line);
69
+ expect(result).toEqual({ message: "hello", status: "ok" });
70
+ });
71
+
72
+ test("parseDataLine should parse valid JSON data with 'data:' prefix (no space)", () => {
73
+ const line = 'data:{"message":"hello","status":"ok"}';
74
+ const result = parseDataLine(line);
75
+ expect(result).toEqual({ message: "hello", status: "ok" });
76
+ });
77
+
78
+ test("parseDataLine should throw error for malformed JSON", () => {
79
+ const line = "data: {invalid json}";
80
+ expect(() => parseDataLine(line)).toThrow(
81
+ "Malformed JSON sent from server",
82
+ );
83
+ });
84
+
85
+ test("parseDataLine should throw error when data contains error field", () => {
86
+ const line = 'data: {"error":"something went wrong"}';
87
+ expect(() => parseDataLine(line)).toThrow(
88
+ "Error streaming response: something went wrong",
89
+ );
90
+ });
91
+
92
+ test("parseDataLine should handle empty objects", () => {
93
+ const line = "data: {}";
94
+ const result = parseDataLine(line);
95
+ expect(result).toEqual({});
96
+ });
97
+
98
+ test("parseDataLine should handle arrays", () => {
99
+ const line = "data: [1,2,3]";
100
+ const result = parseDataLine(line);
101
+ expect(result).toEqual([1, 2, 3]);
102
+ });
103
+
104
+ test("parseDataLine should handle nested objects", () => {
105
+ const line = 'data: {"user":{"name":"John","age":30}}';
106
+ const result = parseDataLine(line);
107
+ expect(result).toEqual({ user: { name: "John", age: 30 } });
108
+ });
109
+ });
package/src/stream.ts CHANGED
@@ -2,7 +2,7 @@ export async function* toAsyncIterable(
2
2
  nodeReadable: NodeJS.ReadableStream,
3
3
  ): AsyncGenerator<Uint8Array> {
4
4
  for await (const chunk of nodeReadable) {
5
- // @ts-expect-error
5
+ // @ts-ignore
6
6
  yield chunk as Uint8Array;
7
7
  }
8
8
  }
@@ -10,6 +10,10 @@ export async function* toAsyncIterable(
10
10
  export async function* streamResponse(
11
11
  response: Response,
12
12
  ): AsyncGenerator<string> {
13
+ if (response.status === 499) {
14
+ return; // In case of client-side cancellation, just return
15
+ }
16
+
13
17
  if (response.status !== 200) {
14
18
  throw new Error(await response.text());
15
19
  }
@@ -21,26 +25,34 @@ export async function* streamResponse(
21
25
  // Get the major version of Node.js
22
26
  const nodeMajorVersion = parseInt(process.versions.node.split(".")[0], 10);
23
27
 
24
- if (nodeMajorVersion >= 20) {
25
- // Use the new API for Node 20 and above
26
- const stream = (ReadableStream as any).from(response.body);
27
- for await (const chunk of stream.pipeThrough(
28
- new TextDecoderStream("utf-8"),
29
- )) {
30
- yield chunk;
28
+ try {
29
+ if (nodeMajorVersion >= 20) {
30
+ // Use the new API for Node 20 and above
31
+ const stream = (ReadableStream as any).from(response.body);
32
+ for await (const chunk of stream.pipeThrough(
33
+ new TextDecoderStream("utf-8"),
34
+ )) {
35
+ yield chunk;
36
+ }
37
+ } else {
38
+ // Fallback for Node versions below 20
39
+ // Streaming with this method doesn't work as version 20+ does
40
+ const decoder = new TextDecoder("utf-8");
41
+ const nodeStream = response.body as unknown as NodeJS.ReadableStream;
42
+ for await (const chunk of toAsyncIterable(nodeStream)) {
43
+ yield decoder.decode(chunk, { stream: true });
44
+ }
31
45
  }
32
- } else {
33
- // Fallback for Node versions below 20
34
- // Streaming with this method doesn't work as version 20+ does
35
- const decoder = new TextDecoder("utf-8");
36
- const nodeStream = response.body as unknown as NodeJS.ReadableStream;
37
- for await (const chunk of toAsyncIterable(nodeStream)) {
38
- yield decoder.decode(chunk, { stream: true });
46
+ } catch (e) {
47
+ if (e instanceof Error && e.name.startsWith("AbortError")) {
48
+ return; // In case of client-side cancellation, just return
39
49
  }
50
+ throw e;
40
51
  }
41
52
  }
42
53
 
43
- function parseDataLine(line: string): any {
54
+ // Export for testing purposes
55
+ export function parseDataLine(line: string): any {
44
56
  const json = line.startsWith("data: ")
45
57
  ? line.slice("data: ".length)
46
58
  : line.slice("data:".length);
@@ -53,12 +65,20 @@ function parseDataLine(line: string): any {
53
65
 
54
66
  return data;
55
67
  } catch (e) {
68
+ // If the error was thrown by our error check, rethrow it
69
+ if (
70
+ e instanceof Error &&
71
+ e.message.startsWith("Error streaming response:")
72
+ ) {
73
+ throw e;
74
+ }
75
+ // Otherwise it's a JSON parsing error
56
76
  throw new Error(`Malformed JSON sent from server: ${json}`);
57
77
  }
58
78
  }
59
79
 
60
80
  function parseSseLine(line: string): { done: boolean; data: any } {
61
- if (line.startsWith("data: [DONE]")) {
81
+ if (line.startsWith("data:[DONE]") || line.startsWith("data: [DONE]")) {
62
82
  return { done: true, data: undefined };
63
83
  }
64
84
  if (line.startsWith("data:")) {