@outputai/core 0.9.1-next.ca5a2f9.0 → 0.9.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.
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FatalError } from '#errors';
|
|
2
|
-
import { fetch } from 'undici';
|
|
2
|
+
import { EnvHttpProxyAgent, fetch } from 'undici';
|
|
3
3
|
import { serializeFetchResponse, serializeBodyAndInferContentType } from '#helpers/fetch';
|
|
4
4
|
import { createChildLogger } from '#logger';
|
|
5
5
|
import { getDestinations } from '#tracing';
|
|
@@ -8,6 +8,9 @@ import { ACTIVITY_GET_TRACE_DESTINATIONS, ACTIVITY_SEND_HTTP_REQUEST } from '#co
|
|
|
8
8
|
|
|
9
9
|
const log = createChildLogger( 'HttpClient' );
|
|
10
10
|
|
|
11
|
+
/* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
|
|
12
|
+
const dispatcher = new EnvHttpProxyAgent( { allowH2: false } );
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Send a HTTP request.
|
|
13
16
|
*
|
|
@@ -26,7 +29,8 @@ export const sendHttpRequest = createInternalStep( {
|
|
|
26
29
|
const args = {
|
|
27
30
|
method,
|
|
28
31
|
headers: new Headers( headers ?? {} ),
|
|
29
|
-
signal: AbortSignal.timeout( timeout )
|
|
32
|
+
signal: AbortSignal.timeout( timeout ),
|
|
33
|
+
dispatcher
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
const methodsWithBody = [ 'DELETE', 'PATCH', 'POST', 'PUT', 'OPTIONS' ];
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { MockAgent, setGlobalDispatcher } from 'undici';
|
|
3
2
|
import { FatalError } from '#errors';
|
|
4
3
|
import { ACTIVITY_GET_TRACE_DESTINATIONS, ACTIVITY_SEND_HTTP_REQUEST } from '#consts';
|
|
5
4
|
import { serializeBodyAndInferContentType, serializeFetchResponse } from '#helpers/fetch';
|
|
@@ -7,6 +6,15 @@ import { getTraceDestinations, sendHttpRequest } from './index.js';
|
|
|
7
6
|
|
|
8
7
|
const getDestinationsMock = vi.hoisted( () => vi.fn() );
|
|
9
8
|
const createInternalStepMock = vi.hoisted( () => vi.fn( ( { handler } ) => handler ) );
|
|
9
|
+
const fetchMock = vi.hoisted( () => vi.fn() );
|
|
10
|
+
const EnvHttpProxyAgentMock = vi.hoisted( () => vi.fn( function EnvHttpProxyAgent( options ) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
} ) );
|
|
13
|
+
|
|
14
|
+
vi.mock( 'undici', () => ( {
|
|
15
|
+
EnvHttpProxyAgent: EnvHttpProxyAgentMock,
|
|
16
|
+
fetch: fetchMock
|
|
17
|
+
} ) );
|
|
10
18
|
|
|
11
19
|
vi.mock( '#tracing', () => ( {
|
|
12
20
|
getDestinations: getDestinationsMock
|
|
@@ -30,16 +38,20 @@ vi.mock( '#helpers/fetch', () => ( {
|
|
|
30
38
|
serializeFetchResponse: vi.fn()
|
|
31
39
|
} ) );
|
|
32
40
|
|
|
33
|
-
const mockAgent = new MockAgent();
|
|
34
|
-
mockAgent.disableNetConnect();
|
|
35
|
-
|
|
36
|
-
setGlobalDispatcher( mockAgent );
|
|
37
|
-
|
|
38
41
|
const url = 'https://growthx.ai';
|
|
39
42
|
const method = 'GET';
|
|
40
43
|
|
|
44
|
+
const response = ( { ok = true, status = 200, statusText = 'OK', headers = {} } = {} ) => ( {
|
|
45
|
+
ok,
|
|
46
|
+
status,
|
|
47
|
+
statusText,
|
|
48
|
+
headers: new Headers( headers ),
|
|
49
|
+
text: vi.fn()
|
|
50
|
+
} );
|
|
51
|
+
|
|
41
52
|
describe( 'internal_activities component registration', () => {
|
|
42
53
|
it( 'creates internal step components for exported activities', () => {
|
|
54
|
+
expect( EnvHttpProxyAgentMock ).toHaveBeenCalledWith( { allowH2: false } );
|
|
43
55
|
expect( createInternalStepMock ).toHaveBeenNthCalledWith( 1, {
|
|
44
56
|
name: ACTIVITY_SEND_HTTP_REQUEST,
|
|
45
57
|
handler: expect.any( Function )
|
|
@@ -55,18 +67,19 @@ describe( 'internal_activities component registration', () => {
|
|
|
55
67
|
|
|
56
68
|
describe( 'internal_activities/sendHttpRequest', () => {
|
|
57
69
|
beforeEach( async () => {
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
fetchMock.mockReset();
|
|
71
|
+
serializeBodyAndInferContentType.mockReset();
|
|
72
|
+
serializeFetchResponse.mockReset();
|
|
60
73
|
} );
|
|
61
74
|
|
|
62
75
|
it( 'succeeds and returns serialized JSON response', async () => {
|
|
63
76
|
const payload = { a: 1 };
|
|
64
77
|
const method = 'POST';
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
fetchMock.mockResolvedValueOnce( response( {
|
|
80
|
+
status: 200,
|
|
81
|
+
headers: { 'content-type': 'application/json' }
|
|
82
|
+
} ) );
|
|
70
83
|
|
|
71
84
|
// mock utils
|
|
72
85
|
serializeBodyAndInferContentType.mockReturnValueOnce( {
|
|
@@ -81,6 +94,10 @@ describe( 'internal_activities/sendHttpRequest', () => {
|
|
|
81
94
|
// utils mocked: verify calls and returned value
|
|
82
95
|
expect( serializeBodyAndInferContentType ).toHaveBeenCalledTimes( 1 );
|
|
83
96
|
expect( serializeBodyAndInferContentType ).toHaveBeenCalledWith( payload );
|
|
97
|
+
expect( fetchMock ).toHaveBeenCalledWith( url, expect.objectContaining( {
|
|
98
|
+
method,
|
|
99
|
+
dispatcher: expect.any( EnvHttpProxyAgentMock )
|
|
100
|
+
} ) );
|
|
84
101
|
expect( serializeFetchResponse ).toHaveBeenCalledTimes( 1 );
|
|
85
102
|
const respArg = serializeFetchResponse.mock.calls[0][0];
|
|
86
103
|
expect( respArg && typeof respArg.text ).toBe( 'function' );
|
|
@@ -90,7 +107,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
|
|
|
90
107
|
} );
|
|
91
108
|
|
|
92
109
|
it( 'throws FatalError when response.ok is false', async () => {
|
|
93
|
-
|
|
110
|
+
fetchMock.mockResolvedValueOnce( response( { ok: false, status: 500, statusText: 'Internal Server Error' } ) );
|
|
94
111
|
|
|
95
112
|
await expect( sendHttpRequest( { url, method } ) ).rejects
|
|
96
113
|
.toThrow( new FatalError( 'GET https://growthx.ai 500' ) );
|
|
@@ -99,9 +116,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
|
|
|
99
116
|
} );
|
|
100
117
|
|
|
101
118
|
it( 'throws FatalError on timeout failure', async () => {
|
|
102
|
-
|
|
103
|
-
.reply( 200, 'ok', { headers: { 'content-type': 'text/plain' } } )
|
|
104
|
-
.delay( 10_000 );
|
|
119
|
+
fetchMock.mockRejectedValueOnce( new Error( 'The operation was aborted due to timeout' ) );
|
|
105
120
|
|
|
106
121
|
await expect( sendHttpRequest( { url, method, timeout: 250 } ) ).rejects
|
|
107
122
|
.toThrow( new FatalError( 'GET https://growthx.ai The operation was aborted due to timeout' ) );
|
|
@@ -110,8 +125,9 @@ describe( 'internal_activities/sendHttpRequest', () => {
|
|
|
110
125
|
} );
|
|
111
126
|
|
|
112
127
|
it( 'wraps DNS resolution errors (ENOTFOUND) preserving cause message', async () => {
|
|
113
|
-
|
|
114
|
-
.
|
|
128
|
+
fetchMock.mockRejectedValueOnce(
|
|
129
|
+
Object.assign( new Error( 'fetch failed' ), { cause: new Error( 'getaddrinfo ENOTFOUND nonexistent.example.test' ) } )
|
|
130
|
+
);
|
|
115
131
|
|
|
116
132
|
await expect( sendHttpRequest( { url, method } ) ).rejects
|
|
117
133
|
.toThrow( new FatalError( 'GET https://growthx.ai Error: getaddrinfo ENOTFOUND nonexistent.example.test' ) );
|
|
@@ -120,8 +136,9 @@ describe( 'internal_activities/sendHttpRequest', () => {
|
|
|
120
136
|
} );
|
|
121
137
|
|
|
122
138
|
it( 'wraps TCP connection errors (ECONNREFUSED) preserving cause message', async () => {
|
|
123
|
-
|
|
124
|
-
.
|
|
139
|
+
fetchMock.mockRejectedValueOnce(
|
|
140
|
+
Object.assign( new Error( 'fetch failed' ), { cause: new Error( 'connect ECONNREFUSED 127.0.0.1:65500' ) } )
|
|
141
|
+
);
|
|
125
142
|
|
|
126
143
|
await expect( sendHttpRequest( { url, method } ) ).rejects
|
|
127
144
|
.toThrow( new FatalError( 'GET https://growthx.ai Error: connect ECONNREFUSED 127.0.0.1:65500' ) );
|
package/src/worker/proxy.js
CHANGED
|
@@ -5,28 +5,16 @@ const log = createChildLogger( 'Proxy' );
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Routes all `fetch()` calls (including those inside Temporal activities)
|
|
8
|
-
* through an HTTP/HTTPS proxy when standard proxy env vars are set
|
|
9
|
-
*
|
|
10
|
-
* none are set. Invalid URLs are logged and skipped so the worker keeps
|
|
11
|
-
* running.
|
|
12
|
-
*
|
|
13
|
-
* Call once at worker startup, before any network activity.
|
|
8
|
+
* through an HTTP/HTTPS proxy when standard proxy env vars are set.
|
|
9
|
+
* No-op when none are set.
|
|
14
10
|
*/
|
|
15
11
|
export const bootstrapFetchProxy = () => {
|
|
16
|
-
const
|
|
17
|
-
|
|
12
|
+
const httpProxyUrl = process.env.http_proxy ?? process.env.HTTP_PROXY;
|
|
13
|
+
const httpsProxyUrl = process.env.https_proxy ?? process.env.HTTPS_PROXY;
|
|
18
14
|
|
|
19
|
-
if (
|
|
20
|
-
|
|
15
|
+
if ( httpProxyUrl?.length > 0 || httpsProxyUrl?.length > 0 ) {
|
|
16
|
+
log.info( 'Proxy env vars detected, setting up global fetch dispatcher EnvHttpProxyAgent', { httpProxyUrl, httpsProxyUrl } );
|
|
17
|
+
/* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
|
|
18
|
+
setGlobalDispatcher( new EnvHttpProxyAgent( { allowH2: false } ) );
|
|
21
19
|
}
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
new URL( proxyUrl );
|
|
25
|
-
} catch {
|
|
26
|
-
log.warn( 'Ignoring invalid proxy URL', { proxyUrl } );
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
log.info( 'Routing fetch() through HTTP proxy', { proxyUrl } );
|
|
31
|
-
setGlobalDispatcher( new EnvHttpProxyAgent() );
|
|
32
20
|
};
|
package/src/worker/proxy.spec.js
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
const mockSetGlobalDispatcher = vi.fn();
|
|
4
4
|
const MockEnvHttpProxyAgent = vi.fn();
|
|
5
|
+
const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
5
6
|
|
|
6
7
|
vi.mock( 'undici', () => ( {
|
|
7
8
|
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
|
|
@@ -9,63 +10,42 @@ vi.mock( 'undici', () => ( {
|
|
|
9
10
|
} ) );
|
|
10
11
|
|
|
11
12
|
vi.mock( '#logger', () => ( {
|
|
12
|
-
createChildLogger: () =>
|
|
13
|
+
createChildLogger: () => mockLogger
|
|
13
14
|
} ) );
|
|
14
15
|
|
|
15
16
|
describe( 'worker/proxy', () => {
|
|
16
|
-
const
|
|
17
|
+
const proxyEnv = [ 'http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY' ];
|
|
17
18
|
|
|
18
19
|
beforeEach( () => {
|
|
19
20
|
vi.clearAllMocks();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
delete process.env.http_proxy;
|
|
21
|
+
for ( const key of proxyEnv ) {
|
|
22
|
+
delete process.env[key];
|
|
23
|
+
}
|
|
24
24
|
} );
|
|
25
25
|
|
|
26
26
|
afterEach( () => {
|
|
27
|
-
|
|
27
|
+
vi.resetModules();
|
|
28
28
|
} );
|
|
29
29
|
|
|
30
30
|
it( 'does nothing when no proxy env vars are set', async () => {
|
|
31
31
|
const { bootstrapFetchProxy } = await import( './proxy.js' );
|
|
32
32
|
bootstrapFetchProxy();
|
|
33
33
|
|
|
34
|
+
expect( MockEnvHttpProxyAgent ).not.toHaveBeenCalled();
|
|
34
35
|
expect( mockSetGlobalDispatcher ).not.toHaveBeenCalled();
|
|
36
|
+
expect( mockLogger.info ).not.toHaveBeenCalled();
|
|
35
37
|
} );
|
|
36
38
|
|
|
37
|
-
it( 'sets global dispatcher when
|
|
38
|
-
process.env.
|
|
39
|
+
it( 'sets global dispatcher when proxy URL is available', async () => {
|
|
40
|
+
process.env.HTTP_PROXY = 'http://proxy:8080/';
|
|
39
41
|
const { bootstrapFetchProxy } = await import( './proxy.js' );
|
|
40
42
|
bootstrapFetchProxy();
|
|
41
43
|
|
|
42
|
-
expect( MockEnvHttpProxyAgent ).
|
|
44
|
+
expect( MockEnvHttpProxyAgent ).toHaveBeenCalledWith( { allowH2: false } );
|
|
43
45
|
expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const { bootstrapFetchProxy } = await import( './proxy.js' );
|
|
49
|
-
bootstrapFetchProxy();
|
|
50
|
-
|
|
51
|
-
expect( MockEnvHttpProxyAgent ).toHaveBeenCalled();
|
|
52
|
-
expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
|
|
53
|
-
} );
|
|
54
|
-
|
|
55
|
-
it( 'prefers HTTPS_PROXY over HTTP_PROXY for detection', async () => {
|
|
56
|
-
process.env.HTTPS_PROXY = 'http://secure-proxy:8080';
|
|
57
|
-
process.env.HTTP_PROXY = 'http://plain-proxy:8080';
|
|
58
|
-
const { bootstrapFetchProxy } = await import( './proxy.js' );
|
|
59
|
-
bootstrapFetchProxy();
|
|
60
|
-
|
|
61
|
-
expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
|
|
62
|
-
} );
|
|
63
|
-
|
|
64
|
-
it( 'does not set global dispatcher when proxy URL is malformed', async () => {
|
|
65
|
-
process.env.HTTPS_PROXY = 'not a url';
|
|
66
|
-
const { bootstrapFetchProxy } = await import( './proxy.js' );
|
|
67
|
-
bootstrapFetchProxy();
|
|
68
|
-
|
|
69
|
-
expect( mockSetGlobalDispatcher ).not.toHaveBeenCalled();
|
|
46
|
+
expect( mockLogger.info ).toHaveBeenCalledWith(
|
|
47
|
+
'Proxy env vars detected, setting up global fetch dispatcher EnvHttpProxyAgent',
|
|
48
|
+
{ httpProxyUrl: 'http://proxy:8080/', httpsProxyUrl: undefined }
|
|
49
|
+
);
|
|
70
50
|
} );
|
|
71
51
|
} );
|