@outputai/core 0.9.1-dev.9f7b159.0 → 0.9.1-next.0964a83.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.9.1-dev.9f7b159.0",
3
+ "version": "0.9.1-next.0964a83.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- vi.restoreAllMocks();
59
- vi.clearAllMocks();
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
- mockAgent.get( url ).intercept( { path: '/', method } )
67
- .reply( 200, JSON.stringify( { ok: true, value: 42 } ), {
68
- headers: { 'content-type': 'application/json' }
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
- mockAgent.get( url ).intercept( { path: '/', method } ).reply( 500, 'Internal error' );
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
- mockAgent.get( url ).intercept( { path: '/', method } )
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
- mockAgent.get( url ).intercept( { path: '/', method } )
114
- .replyWithError( new Error( 'getaddrinfo ENOTFOUND nonexistent.example.test' ) );
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
- mockAgent.get( url ).intercept( { path: '/', method } )
124
- .replyWithError( new Error( 'connect ECONNREFUSED 127.0.0.1:65500' ) );
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' ) );
@@ -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
- * (`HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`). No-op when
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 proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
17
- process.env.HTTP_PROXY || process.env.http_proxy;
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 ( !proxyUrl ) {
20
- return;
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
  };
@@ -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: () => ( { info: vi.fn(), warn: vi.fn(), error: vi.fn() } )
13
+ createChildLogger: () => mockLogger
13
14
  } ) );
14
15
 
15
16
  describe( 'worker/proxy', () => {
16
- const originalEnv = { ...process.env };
17
+ const proxyEnv = [ 'http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY' ];
17
18
 
18
19
  beforeEach( () => {
19
20
  vi.clearAllMocks();
20
- delete process.env.HTTPS_PROXY;
21
- delete process.env.https_proxy;
22
- delete process.env.HTTP_PROXY;
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
- process.env = { ...originalEnv };
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 HTTPS_PROXY is set', async () => {
38
- process.env.HTTPS_PROXY = 'http://proxy:8080';
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 ).toHaveBeenCalled();
44
+ expect( MockEnvHttpProxyAgent ).toHaveBeenCalledWith( { allowH2: false } );
43
45
  expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
44
- } );
45
-
46
- it( 'sets global dispatcher when HTTP_PROXY is set', async () => {
47
- process.env.HTTP_PROXY = 'http://proxy:8080';
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
  } );