@outputai/http 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.2caa4a1.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,119 @@
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
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);
5
+ const createErrorChain = levels => {
6
+ const root = new Error('level-1');
7
+ Array.from({ length: levels - 1 }, (_, index) => index + 2).reduce((current, level) => {
8
+ const cause = new Error(`level-${level}`);
9
+ current.cause = cause;
10
+ return cause;
11
+ }, root);
12
+ return root;
13
13
  };
14
- describe('fetch/utils', () => {
14
+ describe('instrumented_fetch/utils', () => {
15
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({
16
+ it('serializes standard error properties and a nested cause', () => {
17
+ const cause = new TypeError('cause');
18
+ const error = new Error('failure', { cause });
19
+ error.code = 'E_FAILURE';
20
+ expect(serializeError(error)).toEqual({
35
21
  name: 'Error',
36
- message: 'root',
37
- stack: root.stack,
38
- code: undefined,
22
+ message: 'failure',
23
+ stack: error.stack,
24
+ code: 'E_FAILURE',
39
25
  cause: {
40
26
  name: 'TypeError',
41
- message: 'leaf',
42
- stack: leaf.stack,
27
+ message: 'cause',
28
+ stack: cause.stack,
43
29
  code: undefined,
44
30
  cause: undefined
45
31
  }
46
32
  });
47
33
  });
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>');
34
+ it('limits the serialized cause depth', () => {
35
+ const serialized = serializeError(createErrorChain(7));
36
+ const current = Array.from({ length: 5 }).reduce(cause => cause.cause, serialized);
37
+ expect(current.message).toBe('level-6');
38
+ expect(current.cause).toBe('<Max recursion depth reached>');
71
39
  });
72
40
  });
73
41
  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', () => {
42
+ it('redacts sensitive header names while preserving safe values', () => {
101
43
  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'
44
+ authorization: 'Bearer secret',
45
+ 'x-api-key': 'secret',
46
+ cookie: 'session=secret',
47
+ 'content-type': 'application/json'
110
48
  });
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
49
  expect(redactHeaders(headers)).toEqual({
122
50
  authorization: '[REDACTED]',
123
- 'content-type': 'application/json',
124
- 'x-api-key': '[REDACTED]'
51
+ 'x-api-key': '[REDACTED]',
52
+ cookie: '[REDACTED]',
53
+ 'content-type': 'application/json'
125
54
  });
126
55
  });
127
- it('does not redact benign names that only contained sensitive substrings before', () => {
56
+ it('does not redact exempt or substring-only header names', () => {
128
57
  const headers = new Headers({
129
- Keyboard: 'qwerty',
130
- Secretary: 'admin',
131
- Tokens: 'abc123',
132
- 'Content-Length': '123'
58
+ 'x-csrf-token': 'csrf-value',
59
+ 'public-key-pins': 'pin-value',
60
+ keyboard: 'keyboard-value',
61
+ tokens: 'token-count'
133
62
  });
134
63
  expect(redactHeaders(headers)).toEqual({
135
- keyboard: 'qwerty',
136
- secretary: 'admin',
137
- tokens: 'abc123',
138
- 'content-length': '123'
139
- });
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
- 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' });
64
+ 'x-csrf-token': 'csrf-value',
65
+ 'public-key-pins': 'pin-value',
66
+ keyboard: 'keyboard-value',
67
+ tokens: 'token-count'
190
68
  });
191
69
  });
192
70
  });
193
71
  describe('parseBody', () => {
194
- it('parses JSON when content-type is application/json', async () => {
72
+ it('parses JSON bodies without consuming the original', async () => {
195
73
  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
74
  headers: { 'content-type': 'application/json; charset=utf-8' }
203
75
  });
204
- await expect(parseBody(response)).resolves.toEqual([1, 2]);
76
+ await expect(parseBody(response)).resolves.toEqual({ ok: true });
77
+ await expect(response.json()).resolves.toEqual({ ok: true });
205
78
  });
206
- it('returns text when content-type is not JSON', async () => {
207
- const response = new Response('hello', {
79
+ it('returns non-JSON and invalid JSON bodies as text', async () => {
80
+ const textResponse = new Response('plain text', {
208
81
  headers: { 'content-type': 'text/plain' }
209
82
  });
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', {
83
+ const invalidJsonRequest = new Request('https://example.com', {
238
84
  method: 'POST',
239
85
  headers: { 'content-type': 'application/json' },
240
- body: JSON.stringify({ a: 1 })
86
+ body: '{invalid'
241
87
  });
242
- await expect(parseBody(request)).resolves.toEqual({ a: 1 });
88
+ await expect(parseBody(textResponse)).resolves.toBe('plain text');
89
+ await expect(parseBody(invalidJsonRequest)).resolves.toBe('{invalid');
243
90
  });
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
91
+ it('returns an empty string for an empty JSON body', async () => {
92
+ const response = new Response('', {
93
+ headers: { 'content-type': 'application/json' }
250
94
  });
251
- await expect(parseBody(request)).resolves.toBe(raw);
95
+ await expect(parseBody(response)).resolves.toBe('');
252
96
  });
253
97
  });
254
98
  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', () => {
99
+ it('stores an immutable, non-enumerable request id', () => {
261
100
  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({
101
+ addRequestIdToResponse(response, 'request-1');
102
+ expect(response[requestIdSymbol]).toBe('request-1');
103
+ expect(Object.getOwnPropertyDescriptor(response, requestIdSymbol)).toEqual({
104
+ value: 'request-1',
266
105
  enumerable: false,
267
106
  configurable: false,
268
- writable: false,
269
- value: 'req-456'
107
+ writable: false
270
108
  });
271
109
  });
272
- it('propagates the request id to clones (ky afterResponse passes the clone)', () => {
110
+ it('propagates the request id through repeated clones', () => {
273
111
  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');
112
+ addRequestIdToResponse(response, 'request-clone');
113
+ const clone = response.clone();
114
+ const grandchild = clone.clone();
115
+ expect(clone[requestIdSymbol]).toBe('request-clone');
116
+ expect(grandchild[requestIdSymbol]).toBe('request-clone');
280
117
  });
281
118
  });
282
119
  });
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-dev.b7b2fbe.0",
3
+ "version": "0.10.1-next.2caa4a1.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-dev.b7b2fbe.0"
19
+ "@outputai/core": "0.10.1-next.2caa4a1.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
- });