@output.ai/core 0.1.18-dev.pr32-foo → 0.2.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 +2 -1
- package/src/consts.js +1 -1
- package/src/index.d.ts +131 -12
- package/src/index.js +5 -4
- package/src/interface/validations/static.js +8 -6
- package/src/interface/validations/static.spec.js +56 -7
- package/src/interface/webhook.js +50 -17
- package/src/interface/webhook.spec.js +122 -0
- package/src/internal_activities/index.js +44 -22
- package/src/internal_activities/index.spec.js +99 -0
- package/src/tracing/index.d.ts +20 -19
- package/src/utils/index.d.ts +59 -14
- package/src/utils/utils.js +107 -0
- package/src/utils/utils.spec.js +203 -1
- package/src/worker/loader.js +3 -3
- package/src/worker/loader.spec.js +4 -4
- package/src/tracing/processors/local/temp/traces/1767713888257_continue_as_new-19b2e908-d403-438f-af77-080d43823bea.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767713888294_continue_as_new-19b2e908-d403-438f-af77-080d43823bea.trace +0 -6
- package/src/tracing/processors/local/temp/traces/1767728879418_continue_as_new-20b3caf3-bdfe-4083-9840-95b8cb669c7c.trace +0 -3
- package/src/tracing/processors/local/temp/traces/1767728961526_continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767728961568_continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767729551367_continue_as_new-33a8b9ac-2c3d-4afe-bfa3-9f07d444dfe8.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767729551409_continue_as_new-33a8b9ac-2c3d-4afe-bfa3-9f07d444dfe8.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767729584838_continue_as_new-d18f20bb-f97a-4bfa-bfc1-48dd72b9fedf.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767729584880_continue_as_new-d18f20bb-f97a-4bfa-bfc1-48dd72b9fedf.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767730300476_continue_as_new-7887230c-c45f-4393-b26c-eae7b9f095a7.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767801228317_continue_as_new-7887230c-c45f-4393-b26c-eae7b9f095a7.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767801231585_continue_as_new-717fd020-1b29-411a-9a8c-974539c362af.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767801231616_continue_as_new-717fd020-1b29-411a-9a8c-974539c362af.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767801234199_continue_as_new-3c43d860-c2e0-4a04-808b-2676bf896426.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767801234233_continue_as_new-3c43d860-c2e0-4a04-808b-2676bf896426.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767891175397_continue_as_new-ee44babc-7690-4488-a24d-3ff258591bee.trace +0 -4
- package/src/tracing/processors/local/temp/traces/1767891175445_continue_as_new-ee44babc-7690-4488-a24d-3ff258591bee.trace +0 -4
- package/src/worker/temp/__activity_options.js +0 -12
- package/src/worker/temp/__workflows_entrypoint.js +0 -21
|
@@ -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
|
+
} );
|
package/src/tracing/index.d.ts
CHANGED
|
@@ -6,41 +6,42 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
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
|
-
*
|
|
16
|
+
* Record the start of an event on the default trace for the current workflow.
|
|
17
17
|
*
|
|
18
|
-
* @param
|
|
19
|
-
* @param
|
|
20
|
-
* @param
|
|
21
|
-
* @param
|
|
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).
|
|
22
23
|
*/
|
|
23
|
-
addEventStart( args: { id: string; kind: string; name: string; details:
|
|
24
|
+
addEventStart( args: { id: string; kind: string; name: string; details: unknown } ): void;
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* Record the end of an event on the default trace for the current workflow.
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
+
* Use the same id as the start phase to correlate phases.
|
|
29
30
|
*
|
|
30
|
-
* @param
|
|
31
|
-
* @param
|
|
32
|
-
* @
|
|
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).
|
|
33
34
|
*/
|
|
34
|
-
addEventEnd( args: { id: string; details:
|
|
35
|
+
addEventEnd( args: { id: string; details: unknown } ): void;
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
+
* Record an error for an event on the default trace for the current workflow.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
+
* Use the same id as the start phase to correlate phases.
|
|
40
41
|
*
|
|
41
|
-
* @param
|
|
42
|
-
* @param
|
|
43
|
-
* @
|
|
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.
|
|
44
45
|
*/
|
|
45
|
-
addEventError( args: { id: string; details:
|
|
46
|
+
addEventError( args: { id: string; details: unknown } ): void;
|
|
46
47
|
};
|
package/src/utils/index.d.ts
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Return the directory of the file invoking the code that called this function
|
|
3
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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(
|
|
17
|
+
export function clone( object: object ): object;
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* @
|
|
20
|
+
* Receives an error as argument and throws it.
|
|
21
|
+
*
|
|
22
|
+
* @param error
|
|
23
|
+
* @throws {Error}
|
|
18
24
|
*/
|
|
19
|
-
export function throws(
|
|
25
|
+
export function throws( error: Error ): void;
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* @param
|
|
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,
|
|
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;
|
package/src/utils/utils.js
CHANGED
|
@@ -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
|
+
};
|
package/src/utils/utils.spec.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { clone, mergeActivityOptions, serializeBodyAndInferContentType, serializeFetchResponse } from './utils.js';
|
|
4
|
+
// Response is available globally in Node 18+ (undici)
|
|
3
5
|
|
|
4
6
|
describe( 'clone', () => {
|
|
5
7
|
it( 'produces a deep copy without shared references', () => {
|
|
@@ -14,6 +16,206 @@ describe( 'clone', () => {
|
|
|
14
16
|
} );
|
|
15
17
|
} );
|
|
16
18
|
|
|
19
|
+
describe( 'serializeFetchResponse', () => {
|
|
20
|
+
it( 'serializes JSON response body and flattens headers', async () => {
|
|
21
|
+
const payload = { a: 1, b: 'two' };
|
|
22
|
+
const response = new Response( JSON.stringify( payload ), {
|
|
23
|
+
status: 200,
|
|
24
|
+
statusText: 'OK',
|
|
25
|
+
headers: { 'content-type': 'application/json' }
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
const result = await serializeFetchResponse( response );
|
|
29
|
+
expect( result.status ).toBe( 200 );
|
|
30
|
+
expect( result.ok ).toBe( true );
|
|
31
|
+
expect( result.statusText ).toBe( 'OK' );
|
|
32
|
+
expect( result.headers['content-type'] ).toContain( 'application/json' );
|
|
33
|
+
expect( result.body ).toEqual( payload );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'serializes text/* response via text()', async () => {
|
|
37
|
+
const bodyText = 'hello world';
|
|
38
|
+
const response = new Response( bodyText, {
|
|
39
|
+
status: 201,
|
|
40
|
+
statusText: 'Created',
|
|
41
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
const result = await serializeFetchResponse( response );
|
|
45
|
+
expect( result.status ).toBe( 201 );
|
|
46
|
+
expect( result.ok ).toBe( true );
|
|
47
|
+
expect( result.statusText ).toBe( 'Created' );
|
|
48
|
+
expect( result.headers['content-type'] ).toContain( 'text/plain' );
|
|
49
|
+
expect( result.body ).toBe( bodyText );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
if ( typeof ReadableStream !== 'undefined' ) {
|
|
53
|
+
it( 'serializes ReadableStream body for text/* via text()', async () => {
|
|
54
|
+
const encoder = new TextEncoder();
|
|
55
|
+
const chunk = encoder.encode( 'streamed text' );
|
|
56
|
+
const stream = new ReadableStream( {
|
|
57
|
+
start( controller ) {
|
|
58
|
+
controller.enqueue( chunk );
|
|
59
|
+
controller.close();
|
|
60
|
+
}
|
|
61
|
+
} );
|
|
62
|
+
const response = new Response( stream, {
|
|
63
|
+
status: 200,
|
|
64
|
+
statusText: 'OK',
|
|
65
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
66
|
+
} );
|
|
67
|
+
|
|
68
|
+
const result = await serializeFetchResponse( response );
|
|
69
|
+
expect( result.status ).toBe( 200 );
|
|
70
|
+
expect( result.ok ).toBe( true );
|
|
71
|
+
expect( result.statusText ).toBe( 'OK' );
|
|
72
|
+
expect( result.headers['content-type'] ).toContain( 'text/plain' );
|
|
73
|
+
expect( result.body ).toBe( 'streamed text' );
|
|
74
|
+
} );
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it( 'serializes non-text/non-json response as base64 from arrayBuffer()', async () => {
|
|
78
|
+
const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
|
|
79
|
+
const response = new Response( bytes, {
|
|
80
|
+
status: 200,
|
|
81
|
+
statusText: 'OK',
|
|
82
|
+
headers: { 'content-type': 'application/octet-stream' }
|
|
83
|
+
} );
|
|
84
|
+
|
|
85
|
+
const result = await serializeFetchResponse( response );
|
|
86
|
+
expect( result.status ).toBe( 200 );
|
|
87
|
+
expect( result.ok ).toBe( true );
|
|
88
|
+
expect( result.statusText ).toBe( 'OK' );
|
|
89
|
+
expect( result.headers['content-type'] ).toBe( 'application/octet-stream' );
|
|
90
|
+
expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
it( 'defaults to base64 when content-type header is missing', async () => {
|
|
94
|
+
const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
|
|
95
|
+
const response = new Response( bytes, { status: 200 } );
|
|
96
|
+
// No headers set; content-type resolves to ''
|
|
97
|
+
|
|
98
|
+
const result = await serializeFetchResponse( response );
|
|
99
|
+
expect( result.headers['content-type'] ?? '' ).toBe( '' );
|
|
100
|
+
expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
|
|
101
|
+
} );
|
|
102
|
+
} );
|
|
103
|
+
|
|
104
|
+
describe( 'serializeBodyAndInferContentType', () => {
|
|
105
|
+
it( 'returns undefineds for null payload', () => {
|
|
106
|
+
const { body, contentType } = serializeBodyAndInferContentType( null );
|
|
107
|
+
expect( body ).toBeUndefined();
|
|
108
|
+
expect( contentType ).toBeUndefined();
|
|
109
|
+
} );
|
|
110
|
+
|
|
111
|
+
it( 'returns undefineds for undefined payload', () => {
|
|
112
|
+
const { body, contentType } = serializeBodyAndInferContentType( undefined );
|
|
113
|
+
expect( body ).toBeUndefined();
|
|
114
|
+
expect( contentType ).toBeUndefined();
|
|
115
|
+
} );
|
|
116
|
+
|
|
117
|
+
it( 'handles ArrayBuffer with octet-stream', () => {
|
|
118
|
+
const buf = new ArrayBuffer( 4 );
|
|
119
|
+
const { body, contentType } = serializeBodyAndInferContentType( buf );
|
|
120
|
+
expect( body ).toBe( buf );
|
|
121
|
+
expect( contentType ).toBe( 'application/octet-stream' );
|
|
122
|
+
} );
|
|
123
|
+
|
|
124
|
+
it( 'handles TypedArray with octet-stream', () => {
|
|
125
|
+
const view = new Uint8Array( [ 1, 2, 3 ] );
|
|
126
|
+
const { body, contentType } = serializeBodyAndInferContentType( view );
|
|
127
|
+
expect( body ).toBe( view );
|
|
128
|
+
expect( contentType ).toBe( 'application/octet-stream' );
|
|
129
|
+
} );
|
|
130
|
+
|
|
131
|
+
it( 'handles DataView with octet-stream', () => {
|
|
132
|
+
const ab = new ArrayBuffer( 2 );
|
|
133
|
+
const dv = new DataView( ab );
|
|
134
|
+
const { body, contentType } = serializeBodyAndInferContentType( dv );
|
|
135
|
+
expect( body ).toBe( dv );
|
|
136
|
+
expect( contentType ).toBe( 'application/octet-stream' );
|
|
137
|
+
} );
|
|
138
|
+
|
|
139
|
+
// Environment-provided web types
|
|
140
|
+
if ( typeof URLSearchParams !== 'undefined' ) {
|
|
141
|
+
it( 'passes through URLSearchParams without content type', () => {
|
|
142
|
+
const usp = new URLSearchParams( { a: '1', b: 'two' } );
|
|
143
|
+
const { body, contentType } = serializeBodyAndInferContentType( usp );
|
|
144
|
+
expect( body ).toBe( usp );
|
|
145
|
+
expect( contentType ).toBeUndefined();
|
|
146
|
+
} );
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if ( typeof FormData !== 'undefined' ) {
|
|
150
|
+
it( 'passes through FormData without content type', () => {
|
|
151
|
+
const fd = new FormData();
|
|
152
|
+
fd.append( 'a', '1' );
|
|
153
|
+
const { body, contentType } = serializeBodyAndInferContentType( fd );
|
|
154
|
+
expect( body ).toBe( fd );
|
|
155
|
+
expect( contentType ).toBeUndefined();
|
|
156
|
+
} );
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if ( typeof Blob !== 'undefined' ) {
|
|
160
|
+
it( 'passes through Blob without content type', () => {
|
|
161
|
+
const blob = new Blob( [ 'abc' ], { type: 'text/plain' } );
|
|
162
|
+
const { body, contentType } = serializeBodyAndInferContentType( blob );
|
|
163
|
+
expect( body ).toBe( blob );
|
|
164
|
+
expect( contentType ).toBeUndefined();
|
|
165
|
+
} );
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if ( typeof File !== 'undefined' ) {
|
|
169
|
+
it( 'passes through File without content type', () => {
|
|
170
|
+
const file = new File( [ 'abc' ], 'a.txt', { type: 'text/plain' } );
|
|
171
|
+
const { body, contentType } = serializeBodyAndInferContentType( file );
|
|
172
|
+
expect( body ).toBe( file );
|
|
173
|
+
expect( contentType ).toBeUndefined();
|
|
174
|
+
} );
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
it( 'passes through async iterator without content type', () => {
|
|
178
|
+
const asyncIter = ( async function *() {
|
|
179
|
+
yield 'chunk';
|
|
180
|
+
} )();
|
|
181
|
+
const { body, contentType } = serializeBodyAndInferContentType( asyncIter );
|
|
182
|
+
expect( typeof body[Symbol.asyncIterator] ).toBe( 'function' );
|
|
183
|
+
expect( contentType ).toBeUndefined();
|
|
184
|
+
} );
|
|
185
|
+
|
|
186
|
+
it( 'passes through Node Readable without content type', () => {
|
|
187
|
+
const readable = Readable.from( [ 'a', 'b' ] );
|
|
188
|
+
const { body, contentType } = serializeBodyAndInferContentType( readable );
|
|
189
|
+
expect( body ).toBe( readable );
|
|
190
|
+
expect( contentType ).toBeUndefined();
|
|
191
|
+
} );
|
|
192
|
+
|
|
193
|
+
it( 'serializes plain object as JSON with JSON content type', () => {
|
|
194
|
+
const input = { a: 1, b: 'two' };
|
|
195
|
+
const { body, contentType } = serializeBodyAndInferContentType( input );
|
|
196
|
+
expect( body ).toBe( JSON.stringify( input ) );
|
|
197
|
+
expect( contentType ).toBe( 'application/json; charset=UTF-8' );
|
|
198
|
+
} );
|
|
199
|
+
|
|
200
|
+
it( 'serializes string primitive with text/plain content type', () => {
|
|
201
|
+
const { body, contentType } = serializeBodyAndInferContentType( 'hello' );
|
|
202
|
+
expect( body ).toBe( 'hello' );
|
|
203
|
+
expect( contentType ).toBe( 'text/plain; charset=UTF-8' );
|
|
204
|
+
} );
|
|
205
|
+
|
|
206
|
+
it( 'serializes number primitive with text/plain content type', () => {
|
|
207
|
+
const { body, contentType } = serializeBodyAndInferContentType( 42 );
|
|
208
|
+
expect( body ).toBe( '42' );
|
|
209
|
+
expect( contentType ).toBe( 'text/plain; charset=UTF-8' );
|
|
210
|
+
} );
|
|
211
|
+
|
|
212
|
+
it( 'serializes boolean primitive with text/plain content type', () => {
|
|
213
|
+
const { body, contentType } = serializeBodyAndInferContentType( true );
|
|
214
|
+
expect( body ).toBe( 'true' );
|
|
215
|
+
expect( contentType ).toBe( 'text/plain; charset=UTF-8' );
|
|
216
|
+
} );
|
|
217
|
+
} );
|
|
218
|
+
|
|
17
219
|
describe( 'mergeActivityOptions', () => {
|
|
18
220
|
it( 'recursively merges nested objects', () => {
|
|
19
221
|
const base = {
|
package/src/worker/loader.js
CHANGED
|
@@ -2,10 +2,10 @@ import { basename, dirname, join } from 'node:path';
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { EOL } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { getTraceDestinations,
|
|
5
|
+
import { getTraceDestinations, sendHttpRequest } from '#internal_activities';
|
|
6
6
|
import { importComponents } from './loader_tools.js';
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
ACTIVITY_SEND_HTTP_REQUEST,
|
|
9
9
|
ACTIVITY_OPTIONS_FILENAME,
|
|
10
10
|
SHARED_STEP_PREFIX,
|
|
11
11
|
WORKFLOWS_INDEX_FILENAME,
|
|
@@ -50,7 +50,7 @@ export async function loadActivities( target ) {
|
|
|
50
50
|
writeActivityOptionsFile( activityOptionsMap );
|
|
51
51
|
|
|
52
52
|
// system activities
|
|
53
|
-
activities[
|
|
53
|
+
activities[ACTIVITY_SEND_HTTP_REQUEST] = sendHttpRequest;
|
|
54
54
|
activities[ACTIVITY_GET_TRACE_DESTINATIONS] = getTraceDestinations;
|
|
55
55
|
return activities;
|
|
56
56
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
|
|
3
3
|
vi.mock( '#consts', () => ( {
|
|
4
|
-
|
|
4
|
+
ACTIVITY_SEND_HTTP_REQUEST: '__internal#sendHttpRequest',
|
|
5
5
|
ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations',
|
|
6
6
|
WORKFLOWS_INDEX_FILENAME: '__workflows_entrypoint.js',
|
|
7
7
|
WORKFLOW_CATALOG: 'catalog',
|
|
@@ -9,10 +9,10 @@ vi.mock( '#consts', () => ( {
|
|
|
9
9
|
SHARED_STEP_PREFIX: '/shared'
|
|
10
10
|
} ) );
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const sendHttpRequestMock = vi.fn();
|
|
13
13
|
const getTraceDestinationsMock = vi.fn();
|
|
14
14
|
vi.mock( '#internal_activities', () => ( {
|
|
15
|
-
|
|
15
|
+
sendHttpRequest: sendHttpRequestMock,
|
|
16
16
|
getTraceDestinations: getTraceDestinationsMock
|
|
17
17
|
} ) );
|
|
18
18
|
|
|
@@ -40,7 +40,7 @@ describe( 'worker/loader', () => {
|
|
|
40
40
|
|
|
41
41
|
const activities = await loadActivities( '/root' );
|
|
42
42
|
expect( activities['/a#Act1'] ).toBeTypeOf( 'function' );
|
|
43
|
-
expect( activities['__internal#
|
|
43
|
+
expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
|
|
44
44
|
|
|
45
45
|
// options file written with the collected map
|
|
46
46
|
expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
{"kind":"workflow","phase":"start","name":"continue_as_new","id":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713888277,"details":{"value":1}}
|
|
2
|
-
{"kind":"step","phase":"start","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713888285,"details":1}
|
|
3
|
-
{"kind":"step","phase":"end","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713888285,"details":2}
|
|
4
|
-
{"kind":"workflow","phase":"end","name":"continue_as_new","id":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713888293,"details":"<continued_as_new>"}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
{"kind":"workflow","phase":"start","name":"continue_as_new","id":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713889325,"details":{"value":2}}
|
|
2
|
-
{"kind":"step","phase":"start","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713889333,"details":2}
|
|
3
|
-
{"kind":"step","phase":"end","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713889334,"details":3}
|
|
4
|
-
{"kind":"internal_step","phase":"start","name":"__internal#getTraceDestinations","id":"2","parentId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713889345,"details":{"startTime":"2026-01-06T15:38:08.294Z","workflowId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","workflowName":"continue_as_new"}}
|
|
5
|
-
{"kind":"internal_step","phase":"end","name":"__internal#getTraceDestinations","id":"2","parentId":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713889345,"details":{"local":"/Users/sz/Dev/output/test_workflows/logs/runs/continue_as_new/2026-01-06-15-38-08-294Z_continue_as_new-19b2e908-d403-438f-af77-080d43823bea.json","remote":null}}
|
|
6
|
-
{"kind":"workflow","phase":"end","name":"continue_as_new","id":"continue_as_new-19b2e908-d403-438f-af77-080d43823bea","timestamp":1767713889351,"details":{"output":{"result":3},"trace":{"destinations":{"local":"/Users/sz/Dev/output/test_workflows/logs/runs/continue_as_new/2026-01-06-15-38-08-294Z_continue_as_new-19b2e908-d403-438f-af77-080d43823bea.json","remote":null}}}}
|
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
{"kind":"workflow","phase":"start","name":"continue_as_new","id":"continue_as_new-20b3caf3-bdfe-4083-9840-95b8cb669c7c","timestamp":1767728879442,"details":{"value":1}}
|
|
2
|
-
{"kind":"step","phase":"start","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-20b3caf3-bdfe-4083-9840-95b8cb669c7c","timestamp":1767728879450,"details":1}
|
|
3
|
-
{"kind":"step","phase":"end","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-20b3caf3-bdfe-4083-9840-95b8cb669c7c","timestamp":1767728879451,"details":2}
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
{"kind":"workflow","phase":"start","name":"continue_as_new","id":"continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40","timestamp":1767728961548,"details":{"value":1}}
|
|
2
|
-
{"kind":"step","phase":"start","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40","timestamp":1767728961556,"details":1}
|
|
3
|
-
{"kind":"step","phase":"end","name":"/app/test_workflows/dist/continue_as_new#increment","id":"1","parentId":"continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40","timestamp":1767728961557,"details":2}
|
|
4
|
-
{"kind":"workflow","phase":"end","name":"continue_as_new","id":"continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40","timestamp":1767728961567,"details":"<continued_as_new>"}
|