@outputai/http 0.9.1-next.6fe398d.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.
@@ -2,6 +2,8 @@ import * as undici from 'undici';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { logRequest, logResponse, logError, logFailure } from './logger.js';
4
4
  import { addRequestIdToResponse } from './utils.js';
5
+ /* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
6
+ const customDispatcher = new undici.EnvHttpProxyAgent({ allowH2: false });
5
7
  /*
6
8
  * Unifies undici and nodes realms
7
9
  * https://github.com/nodejs/undici#keep-fetch-and-formdata-together
@@ -34,8 +36,10 @@ export const fetch = async (input, init) => {
34
36
  const url = request.url;
35
37
  const startedAt = performance.now();
36
38
  await logRequest({ requestId, request });
39
+ // this allows for users not only to override the dispatcher but also to define it as undefined and remove it altogether.
40
+ const dispatcher = Object.hasOwn(init ?? {}, 'dispatcher') ? init?.dispatcher : customDispatcher;
37
41
  try {
38
- const response = await undici.fetch(request);
42
+ const response = await undici.fetch(request, dispatcher ? { dispatcher } : undefined);
39
43
  const durationMs = performance.now() - startedAt;
40
44
  // This enriches the response of the request id, so it is identifiable later.
41
45
  addRequestIdToResponse(response, requestId);
@@ -30,6 +30,7 @@ describe('fetch/index', () => {
30
30
  mockAgent: undefined,
31
31
  previousDispatcher: undefined
32
32
  };
33
+ const fetchWithMockDispatcher = (input, init) => fetch(input, { ...init, dispatcher: undiciCtx.mockAgent });
33
34
  beforeEach(() => {
34
35
  undiciCtx.mockAgent = new MockAgent();
35
36
  undiciCtx.mockAgent.disableNetConnect();
@@ -54,7 +55,7 @@ describe('fetch/index', () => {
54
55
  describe('fetch with MockAgent', () => {
55
56
  it('returns 200, traces request start and response end', async () => {
56
57
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/ok', method: 'GET' }).reply(200, 'hello', { headers: { 'content-type': 'text/plain' } });
57
- const response = await fetch(`${MOCK_ORIGIN}/ok`);
58
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/ok`);
58
59
  expect(response.status).toBe(200);
59
60
  expect(await response.text()).toBe('hello');
60
61
  expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
@@ -76,7 +77,7 @@ describe('fetch/index', () => {
76
77
  });
77
78
  it('uses logError for status > 399 without logging response end', async () => {
78
79
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/missing', method: 'GET' }).reply(404, 'nope', { headers: { 'content-type': 'text/plain' } });
79
- const response = await fetch(`${MOCK_ORIGIN}/missing`);
80
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/missing`);
80
81
  expect(response.status).toBe(404);
81
82
  expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
82
83
  expect(loggerMock.logResponse).not.toHaveBeenCalled();
@@ -88,7 +89,7 @@ describe('fetch/index', () => {
88
89
  });
89
90
  it('treats status 399 as success (logs response end, not HTTP error)', async () => {
90
91
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/edge', method: 'GET' }).reply(399, '', { headers: { 'content-type': 'text/plain' } });
91
- const response = await fetch(`${MOCK_ORIGIN}/edge`);
92
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/edge`);
92
93
  expect(response.status).toBe(399);
93
94
  expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
94
95
  expect(loggerMock.logError).not.toHaveBeenCalled();
@@ -102,7 +103,7 @@ describe('fetch/index', () => {
102
103
  'x-custom': 'plain-value'
103
104
  }
104
105
  }).reply(200, 'ok');
105
- const response = await fetch(`${MOCK_ORIGIN}/with-plain-headers`, {
106
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/with-plain-headers`, {
106
107
  headers: { 'X-Custom': 'plain-value' }
107
108
  });
108
109
  expect(response.status).toBe(200);
@@ -121,7 +122,7 @@ describe('fetch/index', () => {
121
122
  }).reply(200, 'ok');
122
123
  const userHeaders = new Headers();
123
124
  userHeaders.set('X-From-Headers', 'yes');
124
- const response = await fetch(`${MOCK_ORIGIN}/with-headers-instance`, {
125
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/with-headers-instance`, {
125
126
  headers: userHeaders
126
127
  });
127
128
  expect(response.status).toBe(200);
@@ -131,7 +132,7 @@ describe('fetch/index', () => {
131
132
  });
132
133
  it('calls logFailure when the mock responds with replyWithError', async () => {
133
134
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/boom', method: 'GET' }).replyWithError(new Error('simulated network failure'));
134
- await expect(fetch(`${MOCK_ORIGIN}/boom`)).rejects.toThrow('fetch failed');
135
+ await expect(fetchWithMockDispatcher(`${MOCK_ORIGIN}/boom`)).rejects.toThrow('fetch failed');
135
136
  expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
136
137
  expect(loggerMock.logResponse).not.toHaveBeenCalled();
137
138
  expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
@@ -143,7 +144,7 @@ describe('fetch/index', () => {
143
144
  expect(failure.cause.message).toBe('simulated network failure');
144
145
  });
145
146
  it('fails when no mock matches (disabled net)', async () => {
146
- await expect(fetch(`${MOCK_ORIGIN}/unmocked`)).rejects.toThrow();
147
+ await expect(fetchWithMockDispatcher(`${MOCK_ORIGIN}/unmocked`)).rejects.toThrow();
147
148
  expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
148
149
  expect(loggerMock.logResponse).not.toHaveBeenCalled();
149
150
  expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
@@ -151,7 +152,7 @@ describe('fetch/index', () => {
151
152
  });
152
153
  it('passes method and body through to undici for POST JSON', async () => {
153
154
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/create', method: 'POST' }).reply(201, JSON.stringify({ id: 1 }), { headers: { 'content-type': 'application/json' } });
154
- const response = await fetch(`${MOCK_ORIGIN}/create`, {
155
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/create`, {
155
156
  method: 'POST',
156
157
  headers: { 'content-type': 'application/json' },
157
158
  body: JSON.stringify({ name: 'a' })
@@ -162,7 +163,7 @@ describe('fetch/index', () => {
162
163
  });
163
164
  it('works when the second argument is omitted', async () => {
164
165
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/bare', method: 'GET' }).reply(204);
165
- const response = await fetch(`${MOCK_ORIGIN}/bare`);
166
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/bare`);
166
167
  expect(response.status).toBe(204);
167
168
  expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
168
169
  });
@@ -171,7 +172,7 @@ describe('fetch/index', () => {
171
172
  it('accepts a URL object as the first argument', async () => {
172
173
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/from-url', method: 'GET' }).reply(200, 'url-ok', { headers: { 'content-type': 'text/plain' } });
173
174
  const href = new URL('/from-url', `${MOCK_ORIGIN}/`);
174
- const response = await fetch(href);
175
+ const response = await fetchWithMockDispatcher(href);
175
176
  expect(response.status).toBe(200);
176
177
  expect(await response.text()).toBe('url-ok');
177
178
  expect(loggerMock.logRequest.mock.calls[0][0].request.url).toBe(href.href);
@@ -179,7 +180,7 @@ describe('fetch/index', () => {
179
180
  it('accepts a Request as the first argument (no init)', async () => {
180
181
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/req-only', method: 'GET' }).reply(200, 'r1');
181
182
  const input = new Request(`${MOCK_ORIGIN}/req-only`, { method: 'GET' });
182
- const response = await fetch(input);
183
+ const response = await fetchWithMockDispatcher(input);
183
184
  expect(response.status).toBe(200);
184
185
  expect(await response.text()).toBe('r1');
185
186
  const { request } = loggerMock.logRequest.mock.calls[0][0];
@@ -189,7 +190,7 @@ describe('fetch/index', () => {
189
190
  it('accepts Request plus init that overrides method and body', async () => {
190
191
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/req-plus-init', method: 'POST' }).reply(201, JSON.stringify({ saved: true }), { headers: { 'content-type': 'application/json' } });
191
192
  const input = new Request(`${MOCK_ORIGIN}/req-plus-init`, { method: 'GET' });
192
- const response = await fetch(input, {
193
+ const response = await fetchWithMockDispatcher(input, {
193
194
  method: 'POST',
194
195
  headers: { 'content-type': 'application/json' },
195
196
  body: JSON.stringify({ name: 'override' })
@@ -200,7 +201,7 @@ describe('fetch/index', () => {
200
201
  });
201
202
  it('accepts string URL with explicit undefined init', async () => {
202
203
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/explicit-undefined', method: 'GET' }).reply(200, 'ok');
203
- const response = await fetch(`${MOCK_ORIGIN}/explicit-undefined`, undefined);
204
+ const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/explicit-undefined`, undefined);
204
205
  expect(response.status).toBe(200);
205
206
  expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
206
207
  });
@@ -211,7 +212,7 @@ describe('fetch/index', () => {
211
212
  headers: { 'content-type': 'application/json', 'x-request-trace-id': FIXED_REQUEST_ID }
212
213
  }).reply(200, '{}');
213
214
  const href = new URL('/url-post', `${MOCK_ORIGIN}/`);
214
- const response = await fetch(href, {
215
+ const response = await fetchWithMockDispatcher(href, {
215
216
  method: 'POST',
216
217
  headers: { 'Content-Type': 'application/json' },
217
218
  body: '{}'
@@ -219,5 +220,45 @@ describe('fetch/index', () => {
219
220
  expect(response.status).toBe(200);
220
221
  expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
221
222
  });
223
+ it('uses caller-provided dispatcher without dropping init request options', async () => {
224
+ const dispatcher = new MockAgent();
225
+ dispatcher.disableNetConnect();
226
+ dispatcher.get(MOCK_ORIGIN).intercept({
227
+ path: '/custom-dispatcher',
228
+ method: 'POST',
229
+ headers: {
230
+ 'content-type': 'application/json',
231
+ 'x-request-trace-id': FIXED_REQUEST_ID,
232
+ 'x-custom': 'custom-value'
233
+ }
234
+ }).reply(200, 'ok');
235
+ const response = await fetch(`${MOCK_ORIGIN}/custom-dispatcher`, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'Content-Type': 'application/json',
239
+ 'X-Custom': 'custom-value'
240
+ },
241
+ body: '{}',
242
+ dispatcher
243
+ });
244
+ expect(response.status).toBe(200);
245
+ expect(await response.text()).toBe('ok');
246
+ expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
247
+ expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
248
+ await dispatcher.close();
249
+ });
250
+ it('allows dispatcher undefined to disable the default dispatcher', async () => {
251
+ undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({
252
+ path: '/undefined-dispatcher',
253
+ method: 'GET',
254
+ headers: { 'x-request-trace-id': FIXED_REQUEST_ID }
255
+ }).reply(200, 'ok');
256
+ const response = await fetch(`${MOCK_ORIGIN}/undefined-dispatcher`, {
257
+ dispatcher: undefined
258
+ });
259
+ expect(response.status).toBe(200);
260
+ expect(await response.text()).toBe('ok');
261
+ expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
262
+ });
222
263
  });
223
264
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/http",
3
- "version": "0.9.1-next.6fe398d.0",
3
+ "version": "0.9.1",
4
4
  "description": "Framework abstraction to make HTTP calls with tracing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "ky": "1.14.3",
13
13
  "undici": "8.5.0",
14
- "@outputai/core": "0.9.1-next.6fe398d.0"
14
+ "@outputai/core": "0.9.1"
15
15
  },
16
16
  "license": "Apache-2.0",
17
17
  "publishConfig": {