@outputai/http 0.10.1-next.f6a7c1a.0 → 0.11.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.
@@ -1,282 +1,84 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { Response, Request, Headers } from 'undici';
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Headers, Request, Response } from 'undici';
3
3
  import { requestIdSymbol } from '../consts.js';
4
- import { addRequestIdToResponse, parseBody, redactHeaders, serializeError } from './utils.js';
5
- const createMultiLevelError = (levels, depth = 1) => depth === levels ?
6
- new Error(`level-${depth}`) :
7
- new Error(`level-${depth}`, { cause: createMultiLevelError(levels, depth + 1) });
8
- const walkSerializedCause = (root, steps) => {
9
- if (steps === 0) {
10
- return root;
11
- }
12
- return walkSerializedCause(root.cause, steps - 1);
13
- };
14
- describe('fetch/utils', () => {
15
- describe('serializeError', () => {
16
- it('serializes name, message, stack and sets code and cause to undefined when absent', () => {
17
- const err = new Error('boom');
18
- expect(serializeError(err)).toEqual({ name: 'Error', message: 'boom', stack: err.stack, code: undefined, cause: undefined });
19
- });
20
- it('uses the subclass constructor name', () => {
21
- const err = new TypeError('bad type');
22
- expect(serializeError(err).name).toBe('TypeError');
23
- expect(serializeError(err).message).toBe('bad type');
24
- });
25
- it('includes string code when set on the error', () => {
26
- const err = new Error('e');
27
- err.code = 'ENOENT';
28
- expect(serializeError(err).code).toBe('ENOENT');
29
- });
30
- it('serializes Error cause as a nested plain object', () => {
31
- const root = new Error('root');
32
- const leaf = new TypeError('leaf');
33
- root.cause = leaf;
34
- expect(serializeError(root)).toEqual({
35
- name: 'Error',
36
- message: 'root',
37
- stack: root.stack,
38
- code: undefined,
39
- cause: {
40
- name: 'TypeError',
41
- message: 'leaf',
42
- stack: leaf.stack,
43
- code: undefined,
44
- cause: undefined
45
- }
46
- });
47
- });
48
- it('does not recurse into cause when initial depth is already past the limit', () => {
49
- const inner = new Error('inner');
50
- const outer = new Error('outer');
51
- outer.cause = inner;
52
- expect(serializeError(outer, 6).cause).toBe('<Max recursion depth reached>');
53
- });
54
- it('serializes up to five nested Error causes without hitting the sentinel', () => {
55
- const root = createMultiLevelError(5);
56
- const innermost = walkSerializedCause(serializeError(root), 4);
57
- expect(innermost.message).toBe('level-5');
58
- expect(innermost.cause).toBeUndefined();
59
- });
60
- it('replaces cause with the max-depth sentinel on the sixth nested Error', () => {
61
- const root = createMultiLevelError(6);
62
- const innermost = walkSerializedCause(serializeError(root), 5);
63
- expect(innermost.message).toBe('level-6');
64
- expect(innermost.cause).toBe('<Max recursion depth reached>');
65
- });
66
- it('does not expose a seventh error when the chain is longer than the limit', () => {
67
- const root = createMultiLevelError(7);
68
- const innermost = walkSerializedCause(serializeError(root), 5);
69
- expect(innermost.message).toBe('level-6');
70
- expect(innermost.cause).toBe('<Max recursion depth reached>');
71
- });
72
- });
4
+ import { addRequestIdToResponse, parseBody, redactHeaders } from './utils.js';
5
+ describe('instrumented_fetch/utils', () => {
73
6
  describe('redactHeaders', () => {
74
- it('redacts sensitive headers case-insensitively', () => {
75
- const headers = new Headers([
76
- ['Authorization', 'Bearer token123'],
77
- ['X-API-Key', 'secret-key'],
78
- ['apikey', 'another-secret'],
79
- ['X-Auth-Token', 'auth-token'],
80
- ['Secret-Header', 'top-secret'],
81
- ['Password', 'password123'],
82
- ['Private-Key', 'private-key-data'],
83
- ['Cookie', 'session=abc123'],
84
- ['Content-Type', 'application/json'],
85
- ['User-Agent', 'test-agent']
86
- ]);
87
- expect(redactHeaders(headers)).toEqual({
88
- authorization: '[REDACTED]',
89
- 'x-api-key': '[REDACTED]',
90
- apikey: '[REDACTED]',
91
- 'x-auth-token': '[REDACTED]',
92
- 'secret-header': '[REDACTED]',
93
- password: '[REDACTED]',
94
- 'private-key': '[REDACTED]',
95
- cookie: '[REDACTED]',
96
- 'content-type': 'application/json',
97
- 'user-agent': 'test-agent'
98
- });
99
- });
100
- it('leaves non-sensitive headers unchanged', () => {
7
+ it('redacts sensitive header names while preserving safe values', () => {
101
8
  const headers = new Headers({
102
- 'Content-Type': 'application/json',
103
- Accept: 'application/json',
104
- 'Cache-Control': 'no-cache'
105
- });
106
- expect(redactHeaders(headers)).toEqual({
107
- 'content-type': 'application/json',
108
- accept: 'application/json',
109
- 'cache-control': 'no-cache'
9
+ authorization: 'Bearer secret',
10
+ 'x-api-key': 'secret',
11
+ cookie: 'session=secret',
12
+ 'content-type': 'application/json'
110
13
  });
111
- });
112
- it('handles empty Headers', () => {
113
- expect(redactHeaders(new Headers())).toEqual({});
114
- });
115
- it('redacts sensitive keys even when values are empty', () => {
116
- const headers = new Headers([
117
- ['Authorization', ''],
118
- ['Content-Type', 'application/json'],
119
- ['X-API-Key', '']
120
- ]);
121
14
  expect(redactHeaders(headers)).toEqual({
122
15
  authorization: '[REDACTED]',
123
- 'content-type': 'application/json',
124
- 'x-api-key': '[REDACTED]'
16
+ 'x-api-key': '[REDACTED]',
17
+ cookie: '[REDACTED]',
18
+ 'content-type': 'application/json'
125
19
  });
126
20
  });
127
- it('does not redact benign names that only contained sensitive substrings before', () => {
21
+ it('does not redact exempt or substring-only header names', () => {
128
22
  const headers = new Headers({
129
- Keyboard: 'qwerty',
130
- Secretary: 'admin',
131
- Tokens: 'abc123',
132
- 'Content-Length': '123'
133
- });
134
- expect(redactHeaders(headers)).toEqual({
135
- keyboard: 'qwerty',
136
- secretary: 'admin',
137
- tokens: 'abc123',
138
- 'content-length': '123'
23
+ 'x-csrf-token': 'csrf-value',
24
+ 'public-key-pins': 'pin-value',
25
+ keyboard: 'keyboard-value',
26
+ tokens: 'token-count'
139
27
  });
140
- });
141
- it('does not redact exempt headers but still redacts any header whose name contains a key segment', () => {
142
- const headers = new Headers([
143
- ['X-Csrf-Token', 'abc'],
144
- ['Public-Key-Pins', 'pin-sha256=dummy'],
145
- ['ratelimit-tokens-left', '9'],
146
- ['Cache-Key', 'lookup-1'],
147
- ['X-Auth-Token', 'real-secret']
148
- ]);
149
28
  expect(redactHeaders(headers)).toEqual({
150
- 'x-csrf-token': 'abc',
151
- 'public-key-pins': 'pin-sha256=dummy',
152
- 'ratelimit-tokens-left': '9',
153
- 'cache-key': '[REDACTED]',
154
- 'x-auth-token': '[REDACTED]'
155
- });
156
- });
157
- describe('key suffix pattern /key(?![a-z0-9])/i', () => {
158
- it.each([
159
- ['x-api-key', 'hyphen before key at end'],
160
- ['apikey', 'single segment ending in key'],
161
- ['X-LicenseKey', 'camel segment ending in Key'],
162
- ['webhook-signing-key', 'key before end'],
163
- ['pre-key-post', 'key surrounded by hyphens'],
164
- ['encryption_key', 'underscore after key'],
165
- ['signing-key-id', 'key in middle of kebab name'],
166
- ['monkey', 'key followed by end of string (substring key)'],
167
- ['turkey-vulture', 'key in first segment before hyphen'],
168
- ['v1__key', 'non-alnum before key']
169
- ])('redacts %s (%s)', headerName => {
170
- const headers = new Headers([[headerName, 'secret-value']]);
171
- const out = redactHeaders(headers);
172
- const canonical = Object.keys(out)[0];
173
- expect(out[canonical]).toBe('[REDACTED]');
174
- });
175
- it.each([
176
- ['keyboard', 'key followed by letter b'],
177
- ['KeyAccountId', 'key followed by letter a'],
178
- ['WhiskeyBar', 'key followed by letter b'],
179
- ['my-keyring', 'key followed by letter r'],
180
- ['Cache-Control', 'no key substring with allowed lookahead']
181
- ])('does not redact %s (%s)', headerName => {
182
- const headers = new Headers([[headerName, 'visible']]);
183
- const out = redactHeaders(headers);
184
- const canonical = Object.keys(out)[0];
185
- expect(out[canonical]).toBe('visible');
186
- });
187
- it('still applies exempt list when the key rule would match (e.g. public-key-pins)', () => {
188
- const headers = new Headers([['Public-Key-Pins', 'pins-value']]);
189
- expect(redactHeaders(headers)).toEqual({ 'public-key-pins': 'pins-value' });
29
+ 'x-csrf-token': 'csrf-value',
30
+ 'public-key-pins': 'pin-value',
31
+ keyboard: 'keyboard-value',
32
+ tokens: 'token-count'
190
33
  });
191
34
  });
192
35
  });
193
36
  describe('parseBody', () => {
194
- it('parses JSON when content-type is application/json', async () => {
37
+ it('parses JSON bodies without consuming the original', async () => {
195
38
  const response = new Response(JSON.stringify({ ok: true }), {
196
- headers: { 'content-type': 'application/json' }
197
- });
198
- await expect(parseBody(response)).resolves.toEqual({ ok: true });
199
- });
200
- it('parses JSON when content-type includes charset', async () => {
201
- const response = new Response(JSON.stringify([1, 2]), {
202
39
  headers: { 'content-type': 'application/json; charset=utf-8' }
203
40
  });
204
- await expect(parseBody(response)).resolves.toEqual([1, 2]);
41
+ await expect(parseBody(response)).resolves.toEqual({ ok: true });
42
+ await expect(response.json()).resolves.toEqual({ ok: true });
205
43
  });
206
- it('returns text when content-type is not JSON', async () => {
207
- const response = new Response('hello', {
44
+ it('returns non-JSON and invalid JSON bodies as text', async () => {
45
+ const textResponse = new Response('plain text', {
208
46
  headers: { 'content-type': 'text/plain' }
209
47
  });
210
- await expect(parseBody(response)).resolves.toBe('hello');
211
- });
212
- it('uses text branch when content-type is missing', async () => {
213
- const response = new Response('plain');
214
- await expect(parseBody(response)).resolves.toBe('plain');
215
- });
216
- it('returns empty string for empty body (text)', async () => {
217
- const response = new Response('', { headers: { 'content-type': 'text/plain' } });
218
- await expect(parseBody(response)).resolves.toBe('');
219
- });
220
- it('returns empty string for empty body even when content-type is application/json', async () => {
221
- const response = new Response('', { headers: { 'content-type': 'application/json' } });
222
- await expect(parseBody(response)).resolves.toBe('');
223
- });
224
- it('returns raw text when content-type is application/json but the body is not valid JSON', async () => {
225
- const raw = '{ not json';
226
- const response = new Response(raw, { headers: { 'content-type': 'application/json' } });
227
- const result = await parseBody(response);
228
- expect(result).toBe(raw);
229
- expect(result).toBeTypeOf('string');
230
- });
231
- it('does not consume the original body (clone)', async () => {
232
- const response = new Response('read-me', { headers: { 'content-type': 'text/plain' } });
233
- await parseBody(response);
234
- await expect(response.text()).resolves.toBe('read-me');
235
- });
236
- it('parses JSON Request body', async () => {
237
- const request = new Request('https://ex.com', {
48
+ const invalidJsonRequest = new Request('https://example.com', {
238
49
  method: 'POST',
239
50
  headers: { 'content-type': 'application/json' },
240
- body: JSON.stringify({ a: 1 })
51
+ body: '{invalid'
241
52
  });
242
- await expect(parseBody(request)).resolves.toEqual({ a: 1 });
53
+ await expect(parseBody(textResponse)).resolves.toBe('plain text');
54
+ await expect(parseBody(invalidJsonRequest)).resolves.toBe('{invalid');
243
55
  });
244
- it('returns raw text for invalid JSON on a Request with application/json', async () => {
245
- const raw = '{';
246
- const request = new Request('https://ex.com', {
247
- method: 'POST',
248
- headers: { 'content-type': 'application/json' },
249
- body: raw
56
+ it('returns an empty string for an empty JSON body', async () => {
57
+ const response = new Response('', {
58
+ headers: { 'content-type': 'application/json' }
250
59
  });
251
- await expect(parseBody(request)).resolves.toBe(raw);
60
+ await expect(parseBody(response)).resolves.toBe('');
252
61
  });
253
62
  });
254
63
  describe('addRequestIdToResponse', () => {
255
- it('stores request id under requestIdSymbol on the response', () => {
256
- const response = new Response('ok');
257
- addRequestIdToResponse(response, 'req-123');
258
- expect(response[requestIdSymbol]).toBe('req-123');
259
- });
260
- it('defines request id as non-enumerable, non-writable and non-configurable', () => {
64
+ it('stores an immutable, non-enumerable request id', () => {
261
65
  const response = new Response('ok');
262
- addRequestIdToResponse(response, 'req-456');
263
- const descriptor = Object.getOwnPropertyDescriptor(response, requestIdSymbol);
264
- expect(descriptor).toBeDefined();
265
- expect(descriptor).toMatchObject({
66
+ addRequestIdToResponse(response, 'request-1');
67
+ expect(response[requestIdSymbol]).toBe('request-1');
68
+ expect(Object.getOwnPropertyDescriptor(response, requestIdSymbol)).toEqual({
69
+ value: 'request-1',
266
70
  enumerable: false,
267
71
  configurable: false,
268
- writable: false,
269
- value: 'req-456'
72
+ writable: false
270
73
  });
271
74
  });
272
- it('propagates the request id to clones (ky afterResponse passes the clone)', () => {
75
+ it('propagates the request id through repeated clones', () => {
273
76
  const response = new Response('ok');
274
- addRequestIdToResponse(response, 'req-789');
275
- const cloned = response.clone();
276
- expect(cloned[requestIdSymbol]).toBe('req-789');
277
- // recursion: clones of clones inherit too
278
- const grandchild = cloned.clone();
279
- expect(grandchild[requestIdSymbol]).toBe('req-789');
77
+ addRequestIdToResponse(response, 'request-clone');
78
+ const clone = response.clone();
79
+ const grandchild = clone.clone();
80
+ expect(clone[requestIdSymbol]).toBe('request-clone');
81
+ expect(grandchild[requestIdSymbol]).toBe('request-clone');
280
82
  });
281
83
  });
282
84
  });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Options } from 'ky';
2
1
  export type HttpRequestEvent = {
3
2
  requestId: string;
4
3
  method: string;
@@ -13,30 +12,9 @@ export type HttpRequestCostEvent = {
13
12
  url: string;
14
13
  total: number;
15
14
  };
16
- /**
17
- * Creates a ky client.
18
- *
19
- * This client uses a custom fetch that introduces hooks to integrate with Output.ai tracing.
20
- *
21
- * @example
22
- * ```ts
23
- * import { httpClient } from '@outputai/http';
24
- *
25
- * const client = httpClient({
26
- * prefixUrl: 'https://api.example.com',
27
- * timeout: 30000,
28
- * retry: { limit: 3 }
29
- * });
30
- *
31
- * const response = await client.get('users/1');
32
- * const data = await response.json();
33
- * ```
34
- *
35
- * @param options - The ky options to extend the base client.
36
- * @returns A ky instance extended with Output.ai tracing hooks.
37
- */
38
- export declare function httpClient(options?: Options): import("ky").KyInstance;
39
- export { HTTPError, TimeoutError } from 'ky';
40
- export type { Options as HttpClientOptions } from 'ky';
41
- export * from './fetch/index.js';
15
+ export { outputFetch } from './fetch/index.js';
16
+ export { createKyClient } from './ky/index.js';
42
17
  export { addRequestCost } from './cost.js';
18
+ /** Re-export ky library for convenience. */
19
+ export * as ky from 'ky';
20
+ export * as undici from 'undici';
package/dist/index.js CHANGED
@@ -1,30 +1,6 @@
1
- import ky from 'ky';
2
- import { fetch as customFetch } from './fetch/index.js';
3
- /**
4
- * Creates a ky client.
5
- *
6
- * This client uses a custom fetch that introduces hooks to integrate with Output.ai tracing.
7
- *
8
- * @example
9
- * ```ts
10
- * import { httpClient } from '@outputai/http';
11
- *
12
- * const client = httpClient({
13
- * prefixUrl: 'https://api.example.com',
14
- * timeout: 30000,
15
- * retry: { limit: 3 }
16
- * });
17
- *
18
- * const response = await client.get('users/1');
19
- * const data = await response.json();
20
- * ```
21
- *
22
- * @param options - The ky options to extend the base client.
23
- * @returns A ky instance extended with Output.ai tracing hooks.
24
- */
25
- export function httpClient(options = {}) {
26
- return ky.create({ fetch: customFetch, ...options });
27
- }
28
- export { HTTPError, TimeoutError } from 'ky';
29
- export * from './fetch/index.js';
1
+ export { outputFetch } from './fetch/index.js';
2
+ export { createKyClient } from './ky/index.js';
30
3
  export { addRequestCost } from './cost.js';
4
+ /** Re-export ky library for convenience. */
5
+ export * as ky from 'ky';
6
+ export * as undici from 'undici';
@@ -0,0 +1,24 @@
1
+ import type { KyInstance, Options } from 'ky';
2
+ /**
3
+ * Creates a ky client.
4
+ *
5
+ * This client uses a custom fetch that introduces hooks to integrate with Output.ai tracing.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createKyClient } from '@outputai/http';
10
+ *
11
+ * const client = createKyClient({
12
+ * prefix: 'https://api.example.com',
13
+ * timeout: 30000,
14
+ * retry: { limit: 3 }
15
+ * });
16
+ *
17
+ * const response = await client.get('users/1');
18
+ * const data = await response.json();
19
+ * ```
20
+ *
21
+ * @param options - The ky options to extend the base client.
22
+ * @returns A ky instance extended with Output.ai tracing hooks.
23
+ */
24
+ export declare const createKyClient: (options?: Options) => KyInstance;
@@ -0,0 +1,25 @@
1
+ import ky from 'ky';
2
+ import { outputFetch } from '../fetch/index.js';
3
+ /**
4
+ * Creates a ky client.
5
+ *
6
+ * This client uses a custom fetch that introduces hooks to integrate with Output.ai tracing.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { createKyClient } from '@outputai/http';
11
+ *
12
+ * const client = createKyClient({
13
+ * prefix: 'https://api.example.com',
14
+ * timeout: 30000,
15
+ * retry: { limit: 3 }
16
+ * });
17
+ *
18
+ * const response = await client.get('users/1');
19
+ * const data = await response.json();
20
+ * ```
21
+ *
22
+ * @param options - The ky options to extend the base client.
23
+ * @returns A ky instance extended with Output.ai tracing hooks.
24
+ */
25
+ export const createKyClient = (options = {}) => ky.create({ fetch: outputFetch, ...options });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { outputFetch } from '../fetch/index.js';
3
+ const kyMock = vi.hoisted(() => ({
4
+ client: { get: vi.fn() },
5
+ create: vi.fn()
6
+ }));
7
+ vi.mock('ky', () => ({
8
+ default: { create: kyMock.create }
9
+ }));
10
+ import { createKyClient } from './index.js';
11
+ describe('createKyClient', () => {
12
+ beforeEach(() => {
13
+ kyMock.create.mockReset();
14
+ kyMock.create.mockReturnValue(kyMock.client);
15
+ });
16
+ it('creates a Ky client using outputFetch', () => {
17
+ expect(createKyClient()).toBe(kyMock.client);
18
+ expect(kyMock.create).toHaveBeenCalledWith({ fetch: outputFetch });
19
+ });
20
+ it('forwards Ky options', () => {
21
+ const options = {
22
+ prefix: 'https://example.com',
23
+ timeout: 30_000,
24
+ retry: { limit: 3 }
25
+ };
26
+ createKyClient(options);
27
+ expect(kyMock.create).toHaveBeenCalledWith({ fetch: outputFetch, ...options });
28
+ });
29
+ it('allows callers to override fetch', () => {
30
+ const fetch = vi.fn(async () => new Response());
31
+ createKyClient({ fetch });
32
+ expect(kyMock.create).toHaveBeenCalledWith({ fetch });
33
+ });
34
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/http",
3
- "version": "0.10.1-next.f6a7c1a.0",
3
+ "version": "0.11.0",
4
4
  "description": "Framework abstraction to make HTTP calls with tracing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,18 +11,21 @@
11
11
  "files": [
12
12
  "dist"
13
13
  ],
14
+ "peerDependencies": {
15
+ "ky": ">=2 <3",
16
+ "undici": ">=8 <9"
17
+ },
14
18
  "dependencies": {
15
- "ky": "1.14.3",
16
- "undici": "8.5.0",
17
- "@outputai/core": "0.10.1-next.f6a7c1a.0"
19
+ "@outputai/core": "0.11.0"
20
+ },
21
+ "devDependencies": {
22
+ "ky": "2.0.2",
23
+ "undici": "8.9.0"
18
24
  },
19
25
  "license": "Apache-2.0",
20
26
  "publishConfig": {
21
27
  "access": "public"
22
28
  },
23
- "imports": {
24
- "#*": "./dist/*"
25
- },
26
29
  "scripts": {
27
30
  "build": "rm -rf ./dist && tsc"
28
31
  }
@@ -1,13 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { httpClient } from './index.js';
3
- describe('httpClient', () => {
4
- it('passes the injected fetch to ky so requests use the custom implementation', async () => {
5
- const spyFetch = vi.fn((_input, _init) => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })));
6
- const client = httpClient({
7
- fetch: spyFetch,
8
- prefixUrl: 'https://example.com'
9
- });
10
- await client.get('path');
11
- expect(spyFetch).toHaveBeenCalled();
12
- });
13
- });