@output.ai/core 0.1.18 → 0.2.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.
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mocks for module aliases used by webhook.js
4
+ vi.mock( '#consts', () => ( {
5
+ ACTIVITY_SEND_HTTP_REQUEST: '__internal#sendHttpRequest'
6
+ } ) );
7
+
8
+ const validateRequestPayloadMock = vi.fn();
9
+ vi.mock( './validations/static.js', () => ( {
10
+ validateRequestPayload: validateRequestPayloadMock
11
+ } ) );
12
+
13
+ // Minimal, legible mock of @temporalio/workflow APIs used by webhook.js
14
+ const activityFnMock = vi.fn();
15
+ const proxyActivitiesMock = vi.fn( () => ( { ['__internal#sendHttpRequest']: activityFnMock } ) );
16
+
17
+ const storedHandlers = new Map();
18
+ const defineSignalMock = name => name;
19
+ const setHandlerMock = ( signal, fn ) => {
20
+ storedHandlers.set( signal, fn );
21
+ };
22
+
23
+ const workflowInfoMock = vi.fn( () => ( { workflowId: 'wf-123' } ) );
24
+ const sinks = { trace: { addEventStart: vi.fn(), addEventEnd: vi.fn() } };
25
+ const proxySinksMock = vi.fn( async () => sinks );
26
+
27
+ class TestTrigger {
28
+ constructor() {
29
+ this.resolved = false;
30
+ this._resolve = () => {};
31
+ this.promise = new Promise( res => {
32
+ this._resolve = res;
33
+ } );
34
+ }
35
+ resolve( value ) {
36
+ if ( !this.resolved ) {
37
+ this.resolved = true;
38
+ this._resolve( value );
39
+ }
40
+ }
41
+ then( onFulfilled, onRejected ) {
42
+ return this.promise.then( onFulfilled, onRejected );
43
+ }
44
+ }
45
+
46
+ vi.mock( '@temporalio/workflow', () => ( {
47
+ defineSignal: defineSignalMock,
48
+ setHandler: setHandlerMock,
49
+ proxyActivities: proxyActivitiesMock,
50
+ workflowInfo: workflowInfoMock,
51
+ proxySinks: proxySinksMock,
52
+ uuid4: () => 'uuid-mock',
53
+ Trigger: TestTrigger
54
+ } ) );
55
+
56
+ describe( 'interface/webhook', () => {
57
+ beforeEach( () => {
58
+ vi.clearAllMocks();
59
+ storedHandlers.clear();
60
+ } );
61
+
62
+ it( 'sendHttpRequest validates input and calls activity with correct options and args', async () => {
63
+ const { sendHttpRequest } = await import( './webhook.js' );
64
+
65
+ const fakeSerializedResponse = {
66
+ url: 'https://example.com',
67
+ status: 200,
68
+ statusText: 'OK',
69
+ ok: true,
70
+ headers: { 'content-type': 'application/json' },
71
+ body: { ok: true }
72
+ };
73
+ activityFnMock.mockResolvedValueOnce( fakeSerializedResponse );
74
+
75
+ const args = { url: 'https://example.com/api', method: 'GET' };
76
+ const res = await sendHttpRequest( args );
77
+
78
+ // validated
79
+ expect( validateRequestPayloadMock ).toHaveBeenCalledWith( { ...args, payload: undefined, headers: undefined } );
80
+
81
+ // activity proxied with specified options
82
+ expect( proxyActivitiesMock ).toHaveBeenCalledTimes( 1 );
83
+ const optionsArg = proxyActivitiesMock.mock.calls[0][0];
84
+ expect( optionsArg.startToCloseTimeout ).toBe( '3m' );
85
+ expect( optionsArg.retry ).toEqual( expect.objectContaining( {
86
+ initialInterval: '15s',
87
+ maximumAttempts: 3,
88
+ nonRetryableErrorTypes: expect.arrayContaining( [ 'FatalError' ] )
89
+ } ) );
90
+
91
+ // activity invoked with the same args
92
+ expect( activityFnMock ).toHaveBeenCalledWith( { ...args, payload: undefined, headers: undefined } );
93
+ expect( res ).toEqual( fakeSerializedResponse );
94
+ } );
95
+
96
+ it( 'sendPostRequestAndAwaitWebhook posts wrapped payload and resolves on resume signal', async () => {
97
+ const { sendPostRequestAndAwaitWebhook } = await import( './webhook.js' );
98
+
99
+ // Make the inner activity resolve (through sendHttpRequest)
100
+ activityFnMock.mockResolvedValueOnce( {
101
+ url: 'https://webhook.site',
102
+ status: 200,
103
+ statusText: 'OK',
104
+ ok: true,
105
+ headers: {},
106
+ body: null
107
+ } );
108
+
109
+ const url = 'https://webhook.site/ingest';
110
+ const promise = sendPostRequestAndAwaitWebhook( { url, payload: { x: 1 }, headers: { a: 'b' } } );
111
+
112
+ // The activity was called via sendHttpRequest with POST and wrapped payload
113
+ const callArgs = activityFnMock.mock.calls[0][0];
114
+ expect( callArgs.method ).toBe( 'POST' );
115
+ expect( callArgs.url ).toBe( url );
116
+ expect( callArgs.payload ).toEqual( { workflowId: 'wf-123', payload: { x: 1 } } );
117
+ expect( callArgs.headers ).toEqual( { a: 'b' } );
118
+
119
+ // Returns a promise (async function) for the eventual webhook result
120
+ expect( typeof promise.then ).toBe( 'function' );
121
+ } );
122
+ } );
@@ -1,45 +1,67 @@
1
1
  import { FatalError } from '#errors';
2
- import { setMetadata, isStringboolTrue } from '#utils';
2
+ import { fetch } from 'undici';
3
+ import { setMetadata, isStringboolTrue, serializeFetchResponse, serializeBodyAndInferContentType } from '#utils';
3
4
  import { ComponentType } from '#consts';
4
5
  import * as localProcessor from '../tracing/processors/local/index.js';
5
6
  import * as s3Processor from '../tracing/processors/s3/index.js';
6
7
 
7
8
  /**
8
- * Send a post to a given URL
9
+ * Send a HTTP request.
9
10
  *
10
11
  * @param {object} options
11
12
  * @param {string} options.url - The target url
12
- * @param {string} options.workflowId - The current workflow id
13
- * @param {any} options.payload - The payload to send url
13
+ * @param {string} options.method - The HTTP method
14
+ * @param {unknown} [options.payload] - The payload to send url
15
+ * @param {object} [options.headers] - The headers for the request
16
+ * @param {number} [options.timeout] - The timeout for the request (default 30s)
17
+ * @returns {object} The serialized HTTP response
14
18
  * @throws {FatalError}
15
19
  */
16
- export const sendWebhook = async ( { url, workflowId, payload } ) => {
17
- const request = fetch( url, {
18
- method: 'POST',
19
- headers: {
20
- 'Content-Type': 'application/json'
21
- },
22
- body: JSON.stringify( { workflowId, payload } ),
23
- signal: AbortSignal.timeout( 5000 )
24
- } );
25
-
26
- const res = await ( async () => {
20
+ export const sendHttpRequest = async ( { url, method, payload = undefined, headers = undefined, timeout = 30_000 } ) => {
21
+ const args = {
22
+ method,
23
+ headers: new Headers( headers ?? {} ),
24
+ signal: AbortSignal.timeout( timeout )
25
+ };
26
+
27
+ const methodsWithBody = [ 'DELETE', 'PATCH', 'POST', 'PUT', 'OPTIONS' ];
28
+ const hasBodyPayload = ![ undefined, null ].includes( payload );
29
+ if ( methodsWithBody.includes( method ) && hasBodyPayload ) {
30
+ const { body, contentType } = serializeBodyAndInferContentType( payload );
31
+ if ( contentType && !args.headers.has( 'content-type' ) ) {
32
+ args.headers.set( 'Content-Type', contentType );
33
+ }
34
+ Object.assign( args, { body } );
35
+ };
36
+
37
+ const response = await ( async () => {
27
38
  try {
28
- return await request;
29
- } catch {
30
- throw new FatalError( 'Webhook fail: timeout' );
39
+ return await fetch( url, args );
40
+ } catch ( e ) {
41
+ throw new FatalError( `${method} ${url} ${e.cause ?? e.message}` );
31
42
  }
32
43
  } )();
33
44
 
34
- console.log( '[Core.SendWebhook]', res.status, res.statusText );
45
+ console.log( '[Core.sendHttpRequest]', response.status, response.statusText );
35
46
 
36
- if ( !res.ok ) {
37
- throw new FatalError( `Webhook fail: ${res.status}` );
47
+ if ( !response.ok ) {
48
+ throw new FatalError( `${method} ${url} ${response.status}` );
38
49
  }
50
+
51
+ return serializeFetchResponse( response );
39
52
  };
40
53
 
41
- setMetadata( sendWebhook, { type: ComponentType.INTERNAL_STEP } );
54
+ setMetadata( sendHttpRequest, { type: ComponentType.INTERNAL_STEP } );
42
55
 
56
+ /**
57
+ * Resolve and return all possible trace destinations based on env var flags
58
+ *
59
+ * @param {Object} options
60
+ * @param {Date} startTime - Workflow startTime
61
+ * @param {string} workflowId
62
+ * @param {string} workflowName
63
+ * @returns {object} Information about enabled workflows
64
+ */
43
65
  export const getTraceDestinations = ( { startTime, workflowId, workflowName } ) => ( {
44
66
  local: isStringboolTrue( process.env.TRACE_LOCAL_ON ) ? localProcessor.getDestination( { startTime, workflowId, workflowName } ) : null,
45
67
  remote: isStringboolTrue( process.env.TRACE_REMOTE_ON ) ? s3Processor.getDestination( { startTime, workflowId, workflowName } ) : null
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { MockAgent, setGlobalDispatcher } from 'undici';
3
+ import { FatalError } from '#errors';
4
+ import { serializeBodyAndInferContentType, serializeFetchResponse } from '#utils';
5
+ import { sendHttpRequest } from './index.js';
6
+
7
+ vi.mock( '#utils', () => {
8
+ return {
9
+ setMetadata: vi.fn(),
10
+ isStringboolTrue: vi.fn( () => false ),
11
+ serializeBodyAndInferContentType: vi.fn(),
12
+ serializeFetchResponse: vi.fn()
13
+ };
14
+ } );
15
+
16
+ const mockAgent = new MockAgent();
17
+ mockAgent.disableNetConnect();
18
+
19
+ setGlobalDispatcher( mockAgent );
20
+
21
+ const url = 'https://growthx.ai';
22
+ const method = 'GET';
23
+
24
+ describe( 'internal_activities/sendHttpRequest', () => {
25
+ beforeEach( async () => {
26
+ vi.restoreAllMocks();
27
+ vi.clearAllMocks();
28
+ } );
29
+
30
+ it( 'succeeds and returns serialized JSON response', async () => {
31
+ const payload = { a: 1 };
32
+ const method = 'POST';
33
+
34
+ mockAgent.get( url ).intercept( { path: '/', method } )
35
+ .reply( 200, JSON.stringify( { ok: true, value: 42 } ), {
36
+ headers: { 'content-type': 'application/json' }
37
+ } );
38
+
39
+ // mock utils
40
+ serializeBodyAndInferContentType.mockReturnValueOnce( {
41
+ body: JSON.stringify( payload ),
42
+ contentType: 'application/json; charset=UTF-8'
43
+ } );
44
+ const fakeSerialized = { sentinel: true };
45
+ serializeFetchResponse.mockResolvedValueOnce( fakeSerialized );
46
+
47
+ const result = await sendHttpRequest( { url, method, payload } );
48
+
49
+ // utils mocked: verify calls and returned value
50
+ expect( serializeBodyAndInferContentType ).toHaveBeenCalledTimes( 1 );
51
+ expect( serializeBodyAndInferContentType ).toHaveBeenCalledWith( payload );
52
+ expect( serializeFetchResponse ).toHaveBeenCalledTimes( 1 );
53
+ const respArg = serializeFetchResponse.mock.calls[0][0];
54
+ expect( respArg && typeof respArg.text ).toBe( 'function' );
55
+ expect( respArg.status ).toBe( 200 );
56
+ expect( respArg.headers.get( 'content-type' ) ).toContain( 'application/json' );
57
+ expect( result ).toBe( fakeSerialized );
58
+ } );
59
+
60
+ it( 'throws FatalError when response.ok is false', async () => {
61
+ mockAgent.get( url ).intercept( { path: '/', method } ).reply( 500, 'Internal error' );
62
+
63
+ await expect( sendHttpRequest( { url, method } ) ).rejects
64
+ .toThrow( new FatalError( 'GET https://growthx.ai 500' ) );
65
+ expect( serializeFetchResponse ).not.toHaveBeenCalled();
66
+ expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
67
+ } );
68
+
69
+ it( 'throws FatalError on timeout failure', async () => {
70
+ mockAgent.get( url ).intercept( { path: '/', method } )
71
+ .reply( 200, 'ok', { headers: { 'content-type': 'text/plain' } } )
72
+ .delay( 10_000 );
73
+
74
+ await expect( sendHttpRequest( { url, method, timeout: 250 } ) ).rejects
75
+ .toThrow( new FatalError( 'GET https://growthx.ai The operation was aborted due to timeout' ) );
76
+ expect( serializeFetchResponse ).not.toHaveBeenCalled();
77
+ expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
78
+ } );
79
+
80
+ it( 'wraps DNS resolution errors (ENOTFOUND) preserving cause message', async () => {
81
+ mockAgent.get( url ).intercept( { path: '/', method } )
82
+ .replyWithError( new Error( 'getaddrinfo ENOTFOUND nonexistent.example.test' ) );
83
+
84
+ await expect( sendHttpRequest( { url, method } ) ).rejects
85
+ .toThrow( new FatalError( 'GET https://growthx.ai Error: getaddrinfo ENOTFOUND nonexistent.example.test' ) );
86
+ expect( serializeFetchResponse ).not.toHaveBeenCalled();
87
+ expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
88
+ } );
89
+
90
+ it( 'wraps TCP connection errors (ECONNREFUSED) preserving cause message', async () => {
91
+ mockAgent.get( url ).intercept( { path: '/', method } )
92
+ .replyWithError( new Error( 'connect ECONNREFUSED 127.0.0.1:65500' ) );
93
+
94
+ await expect( sendHttpRequest( { url, method } ) ).rejects
95
+ .toThrow( new FatalError( 'GET https://growthx.ai Error: connect ECONNREFUSED 127.0.0.1:65500' ) );
96
+ expect( serializeFetchResponse ).not.toHaveBeenCalled();
97
+ expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
98
+ } );
99
+ } );
@@ -6,42 +6,42 @@
6
6
  */
7
7
 
8
8
  /**
9
- * The public namespace for tracing
9
+ * Public tracing API for recording event phases on the default trace of the current workflow.
10
10
  *
11
11
  * @namespace
12
12
  */
13
13
  export declare const Tracing: {
14
14
 
15
15
  /**
16
- * Adds the start phase of a new event at the default trace for the current workflow.
16
+ * Record the start of an event on the default trace for the current workflow.
17
17
  *
18
- * @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
19
- * @param {string} kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
20
- * @param {string} name - The human friendly name of the Event: query, request, create.
21
- * @param {any} details - All details attached to this Event Phase. DB queried records, HTTP response body.
22
- * @returns {void}
18
+ * @param args - Event information
19
+ * @param args.id - A unique id for the Event, must be the same across all phases: start, end, error.
20
+ * @param args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
21
+ * @param args.name - The human friendly name of the Event: query, request, create.
22
+ * @param args.details - Arbitrary metadata associated with this phase (e.g., payloads, summaries).
23
23
  */
24
- addEventStart( args: { id: string; kind: string; name: string; details: any } ): void; // eslint-disable-line @typescript-eslint/no-explicit-any
24
+ addEventStart( args: { id: string; kind: string; name: string; details: unknown } ): void;
25
25
 
26
26
  /**
27
- * Adds the end phase at an event at the default trace for the current workflow.
27
+ * Record the end of an event on the default trace for the current workflow.
28
28
  *
29
- * It needs to use the same id of the start phase.
29
+ * Use the same id as the start phase to correlate phases.
30
30
  *
31
- * @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
32
- * @param {any} details - All details attached to this Event Phase. DB queried records, HTTP response body.
33
- * @returns {void}
31
+ * @param args - Event information
32
+ * @param args.id - Identifier matching the event's start phase.
33
+ * @param args.details - Arbitrary metadata associated with this phase (e.g., results, response body).
34
34
  */
35
- addEventEnd( args: { id: string; details: any } ): void; // eslint-disable-line @typescript-eslint/no-explicit-any
35
+ addEventEnd( args: { id: string; details: unknown } ): void;
36
36
 
37
37
  /**
38
- * Adds the error phase at an event as error at the default trace for the current workflow.
38
+ * Record an error for an event on the default trace for the current workflow.
39
39
  *
40
- * It needs to use the same id of the start phase.
40
+ * Use the same id as the start phase to correlate phases.
41
41
  *
42
- * @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
43
- * @param {any} details - All details attached to this Event Phase. DB queried records, HTTP response body.
44
- * @returns {void}
42
+ * @param args - Event metadata for the error phase.
43
+ * @param args.id - Identifier matching the event's start phase.
44
+ * @param args.details - Arbitrary metadata associated with this phase, possible error info.
45
45
  */
46
- addEventError( args: { id: string; details: any } ): void; // eslint-disable-line @typescript-eslint/no-explicit-any
46
+ addEventError( args: { id: string; details: unknown } ): void;
47
47
  };
@@ -1,30 +1,37 @@
1
1
  /**
2
- * Return the directory of the file invoking the code that called this function
3
- * Excludes `@output.ai/core`, node, and other internal paths
2
+ * Return the first immediate directory of the file invoking the code that called this function.
3
+ *
4
+ * Excludes `@output.ai/core`, node, and other internal paths.
4
5
  */
5
6
  export function resolveInvocationDir(): string;
6
7
 
7
8
  /**
8
- * Node safe clone implementation that doesn't use global structuredClone()
9
- * @param {object} v
10
- * @returns {object}
9
+ * Node safe clone implementation that doesn't use global structuredClone().
10
+ *
11
+ * Returns a cloned version of the object.
12
+ *
13
+ * Only clones static properties. Getters become static properties.
14
+ *
15
+ * @param object
11
16
  */
12
- export function clone( v: object ): object;
17
+ export function clone( object: object ): object;
13
18
 
14
19
  /**
15
- * Throw given error
16
- * @param {Error} e
17
- * @throws {e}
20
+ * Receives an error as argument and throws it.
21
+ *
22
+ * @param error
23
+ * @throws {Error}
18
24
  */
19
- export function throws( e: Error ): void;
25
+ export function throws( error: Error ): void;
20
26
 
21
27
  /**
22
- * Add metadata "values" property to a given object
23
- * @param {object} target
24
- * @param {object} values
28
+ * Attach given value to an object with the METADATA_ACCESS_SYMBOL symbol as key.
29
+ *
30
+ * @param target
31
+ * @param value
25
32
  * @returns
26
33
  */
27
- export function setMetadata( target: object, values: object ): void;
34
+ export function setMetadata( target: object, value: object ): void;
28
35
 
29
36
  /**
30
37
  * Merge two temporal activity options
@@ -33,3 +40,41 @@ export function mergeActivityOptions(
33
40
  base?: import( '@temporalio/workflow' ).ActivityOptions,
34
41
  ext?: import( '@temporalio/workflow' ).ActivityOptions
35
42
  ): import( '@temporalio/workflow' ).ActivityOptions;
43
+
44
+ /** Represents a {Response} serialized to plain object */
45
+ export type SerializedFetchResponse = {
46
+ /** The response url */
47
+ url: string,
48
+
49
+ /** The response status code */
50
+ status: number,
51
+
52
+ /** The response status text */
53
+ statusText: string,
54
+
55
+ /** Flag indicating if the request succeeded */
56
+ ok: boolean,
57
+
58
+ /** Object with response headers */
59
+ headers: Record<string, string>,
60
+
61
+ /** Response body, either JSON, text or arrayBuffer converter to base64 */
62
+ body: object | string
63
+ };
64
+
65
+ /**
66
+ * Consumes and HTTP Response and serialize it to a plain object
67
+ */
68
+ export function serializeFetchResponse( response: Response ): SerializedFetchResponse;
69
+
70
+ export type SerializedBodyAndContentType = {
71
+ /** The body parsed to string if possible or kept as the types allowed in fetch's POST body */
72
+ body: string | unknown,
73
+ /** The inferred content-type */
74
+ contentType: string | undefined
75
+ };
76
+
77
+ /**
78
+ * Based on the type of a payload, serialized it to be send as the body of a fetch POST request and also infer its Content Type.
79
+ */
80
+ export function serializeBodyAndInferContentType( body: unknown ): SerializedBodyAndContentType;
@@ -42,3 +42,110 @@ export const mergeActivityOptions = ( base = {}, ext = {} ) =>
42
42
  * @returns
43
43
  */
44
44
  export const isStringboolTrue = v => [ '1', 'true', 'on' ].includes( v );
45
+
46
+ /**
47
+ * Consume Fetch's HTTP Response and return a serialized version of it;
48
+ *
49
+ * @param {Response} response
50
+ * @returns {object} Serialized response
51
+ */
52
+ export const serializeFetchResponse = async response => {
53
+ const headers = Object.fromEntries( response.headers );
54
+ const contentType = headers['content-type'] || '';
55
+
56
+ const body = await ( async () => {
57
+ if ( contentType.includes( 'application/json' ) ) {
58
+ return response.json();
59
+ }
60
+ if ( contentType.startsWith( 'text/' ) ) {
61
+ return response.text();
62
+ }
63
+ return response.arrayBuffer().then( buf => Buffer.from( buf ).toString( 'base64' ) );
64
+ } )();
65
+
66
+ return {
67
+ url: response.url,
68
+ status: response.status,
69
+ statusText: response.statusText,
70
+ ok: response.ok,
71
+ headers,
72
+ body
73
+ };
74
+ };
75
+
76
+ /**
77
+ * Duck-typing to detect a Node Readable (Stream) without importing anything
78
+ *
79
+ * @param {unknown} v
80
+ * @returns {boolean}
81
+ */
82
+ const isReadable = v =>
83
+ typeof v === 'object' &&
84
+ typeof v?.read === 'function' &&
85
+ typeof v?.on === 'function' &&
86
+ typeof v?.pipe === 'function' &&
87
+ v?.readable !== false;
88
+
89
+ /**
90
+ * Based on the type of a payload, serialized it to be send as the body of a fetch POST request and also infer its Content Type.
91
+ *
92
+ * Non serializable types versus Content-Type reference (for Node)
93
+ *
94
+ * |Type|Is self-describing)|Inferred type by fetch|Defined mime type|
95
+ * |-|-|-|-}
96
+ * |Blob|yes|`blob.type`||
97
+ * |File|yes|`file.type`||
98
+ * |FormData|yes|"multipart/form-data; boundary=..."||
99
+ * |URLSearchParams|yes|"application/x-www-form-urlencoded;charset=UTF-8"||
100
+ * |ArrayBuffer|no||"application/octet-stream"|
101
+ * |TypedArray (Uint8Array,Uint16Array)||"application/octet-stream"||
102
+ * |DataView|no||"application/octet-stream"|
103
+ * |ReadableStream, Readable, AsyncIterator|no||Can't, because stream must be read|
104
+ *
105
+ * If payload is none of the above types, test it:
106
+ * If the it is an object, serialize using JSON.stringify and set content-type to `application/json`;
107
+ * Else, it is a JS primitive, serialize using JSON.stringify and set content-type to `text/plain`;
108
+ *
109
+ * This implementation is overkill for temporal workflows since the only types available there will be:
110
+ * - URLSearchParams
111
+ * - ArrayBuffer
112
+ * - TypedArrays
113
+ * - DataView
114
+ * - asyncGenerator
115
+ * The others are non deterministic and are not available at runtime, but this function was build to be flexible
116
+ *
117
+ * @see {@link https://fetch.spec.whatwg.org/#bodyinit}
118
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch}
119
+ *
120
+ * @param {unknown} payload
121
+ * @returns {object} An object with the serialized body and inferred content-type
122
+ */
123
+ export const serializeBodyAndInferContentType = payload => {
124
+ const dataTypes = [ Blob, File, URLSearchParams, FormData ];
125
+
126
+ // empty body
127
+ if ( [ null, undefined ].includes( payload ) ) {
128
+ return { body: undefined, contentType: undefined };
129
+ }
130
+
131
+ // Buffer types, covers ArrayBuffer, TypedArrays and DataView
132
+ if ( payload instanceof ArrayBuffer || ArrayBuffer.isView( payload ) ) {
133
+ return { body: payload, contentType: 'application/octet-stream' };
134
+ }
135
+
136
+ // These data types auto assigned mime types
137
+ if ( dataTypes.some( t => payload instanceof t ) ) {
138
+ return { body: payload, contentType: undefined };
139
+ }
140
+
141
+ // ReadableStream, Readable and Async Iterator mimes cant be determined without reading it
142
+ if ( payload instanceof ReadableStream || typeof payload[Symbol.asyncIterator] === 'function' || isReadable( payload ) ) {
143
+ return { body: payload, contentType: undefined };
144
+ }
145
+
146
+ if ( typeof payload === 'object' ) {
147
+ return { body: JSON.stringify( payload ), contentType: 'application/json; charset=UTF-8' };
148
+ }
149
+
150
+ return { body: String( payload ), contentType: 'text/plain; charset=UTF-8' };
151
+ };