@output.ai/core 0.1.18 → 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 +98 -10
- package/src/index.js +4 -3
- 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 -20
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@output.ai/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"@temporalio/workflow": "1.13.1",
|
|
39
39
|
"redis": "5.8.3",
|
|
40
40
|
"stacktrace-parser": "0.1.11",
|
|
41
|
+
"undici": "7.18.2",
|
|
41
42
|
"zod": "4.1.12"
|
|
42
43
|
},
|
|
43
44
|
"license": "Apache-2.0",
|
package/src/consts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const ACTIVITY_SEND_HTTP_REQUEST = '__internal#sendHttpRequest';
|
|
2
2
|
export const ACTIVITY_GET_TRACE_DESTINATIONS = '__internal#getTraceDestinations';
|
|
3
3
|
export const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
|
|
4
4
|
export const SHARED_STEP_PREFIX = '__shared#';
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from 'zod';
|
|
2
2
|
import type { ActivityOptions } from '@temporalio/workflow';
|
|
3
|
+
import type { SerializedFetchResponse } from './utils/index.d.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Expose z from Zod as a convenience.
|
|
@@ -7,6 +8,7 @@ import type { ActivityOptions } from '@temporalio/workflow';
|
|
|
7
8
|
export { z } from 'zod';
|
|
8
9
|
|
|
9
10
|
/**
|
|
11
|
+
* Exports Temporal's sleep() function for advanced use cases.
|
|
10
12
|
* Pause workflow execution for a specified duration.
|
|
11
13
|
*
|
|
12
14
|
* Use this for delay-based throttling when calling external APIs.
|
|
@@ -21,6 +23,8 @@ export { z } from 'zod';
|
|
|
21
23
|
* }
|
|
22
24
|
* ```
|
|
23
25
|
*
|
|
26
|
+
* @see {@link https://docs.temporal.io/develop/typescript/timers}
|
|
27
|
+
*
|
|
24
28
|
* @param ms - Duration to sleep in milliseconds (or a string like '1s', '100ms')
|
|
25
29
|
* @returns A promise that resolves after the specified duration
|
|
26
30
|
*
|
|
@@ -49,6 +53,11 @@ export { continueAsNew } from '@temporalio/workflow';
|
|
|
49
53
|
*/
|
|
50
54
|
export type AnyZodSchema = z.ZodType<any, any, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Allowed HTTP methods for request helpers.
|
|
58
|
+
*/
|
|
59
|
+
export type HttpMethod = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
62
|
* Native Temporal configurations for activities.
|
|
54
63
|
*
|
|
@@ -163,8 +172,11 @@ export type StepFunctionWrapper<StepFunction> =
|
|
|
163
172
|
* - Never call another step from within a step.
|
|
164
173
|
* - Never call a workflow from within a step.
|
|
165
174
|
*
|
|
166
|
-
* @typeParam InputSchema - Zod schema
|
|
167
|
-
* @typeParam OutputSchema - Zod schema
|
|
175
|
+
* @typeParam InputSchema - Zod schema of the fn's input.
|
|
176
|
+
* @typeParam OutputSchema - Zod schema of the fn's return.
|
|
177
|
+
*
|
|
178
|
+
* @throws {@link ValidationError}
|
|
179
|
+
* @throws {@link FatalError}
|
|
168
180
|
*
|
|
169
181
|
* @param params - Step parameters
|
|
170
182
|
* @param params.name - Human-readable step name (must start with a letter or underscore, followed by letters, numbers, or underscores)
|
|
@@ -402,6 +414,11 @@ export type WorkflowFunctionWrapper<WorkflowFunction> =
|
|
|
402
414
|
* }
|
|
403
415
|
* } )
|
|
404
416
|
* ```
|
|
417
|
+
* @typeParam InputSchema - Zod schema of the fn's input.
|
|
418
|
+
* @typeParam OutputSchema - Zod schema of the fn's return.
|
|
419
|
+
*
|
|
420
|
+
* @throws {@link ValidationError}
|
|
421
|
+
* @throws {@link FatalError}
|
|
405
422
|
*
|
|
406
423
|
* @param params - Workflow parameters
|
|
407
424
|
* @param params.name - Human-readable workflow name (must start with a letter or underscore, followed by letters, numbers, or underscores).
|
|
@@ -553,6 +570,12 @@ export type EvaluatorFunctionWrapper<EvaluatorFunction> =
|
|
|
553
570
|
*
|
|
554
571
|
* It is translated to a Temporal Activity.
|
|
555
572
|
*
|
|
573
|
+
* @typeParam InputSchema - Zod schema of the fn's input.
|
|
574
|
+
* @typeParam Result - Return type of the fn, extends EvaluationResult.
|
|
575
|
+
*
|
|
576
|
+
* @throws {@link ValidationError}
|
|
577
|
+
* @throws {@link FatalError}
|
|
578
|
+
*
|
|
556
579
|
* @param params - Evaluator parameters
|
|
557
580
|
* @param params.name - Human-readable evaluator name (must start with a letter or underscore, followed by letters, numbers, or underscores)
|
|
558
581
|
* @param params.description - Description of the evaluator
|
|
@@ -580,17 +603,82 @@ export declare function evaluator<
|
|
|
580
603
|
*/
|
|
581
604
|
|
|
582
605
|
/**
|
|
583
|
-
*
|
|
606
|
+
* Send an POST HTTP request to a URL, optionally with a payload, then wait for a webhook response.
|
|
607
|
+
*
|
|
608
|
+
* The "Content-Type" is inferred from the payload type and can be overridden via the `headers` argument.
|
|
609
|
+
*
|
|
610
|
+
* If the body is not a type natively accepted by the Fetch API, it is serialized to a string: `JSON.stringify()` for objects, or `String()` for primitives.
|
|
611
|
+
*
|
|
612
|
+
* When a body is sent, the payload is wrapped together with the `workflowId` and sent as:
|
|
613
|
+
* @example
|
|
614
|
+
* ```js
|
|
615
|
+
* const finalPayload = {
|
|
616
|
+
* workflowId,
|
|
617
|
+
* payload
|
|
618
|
+
* }
|
|
619
|
+
* ```
|
|
620
|
+
*
|
|
621
|
+
* After dispatching the request, the workflow pauses and waits for a POST to `/workflow/:id/feedback` (where `:id` is the `workflowId`). When the API receives that request, its body is delivered back to the workflow and execution resumes.
|
|
622
|
+
*
|
|
623
|
+
* @example
|
|
624
|
+
* ```js
|
|
625
|
+
* const response = await sendPostRequestAndAwaitWebhook( {
|
|
626
|
+
* url: 'https://example.com/integration',
|
|
627
|
+
* payload: {
|
|
628
|
+
* }
|
|
629
|
+
* } );
|
|
630
|
+
*
|
|
631
|
+
* assert( response, 'the value sent back via the api' );
|
|
632
|
+
* ```
|
|
633
|
+
*
|
|
634
|
+
* @remarks
|
|
635
|
+
* - Only callable from within a workflow function; do not use in steps or evaluators.
|
|
636
|
+
* - Steps and evaluators are activity-based and are not designed to be paused.
|
|
637
|
+
* - If used within steps or evaluators, a compilation error will be raised.
|
|
638
|
+
* - Uses a Temporal Activity to dispatch the HTTP request, working around the runtime limitation for workflows.
|
|
639
|
+
* - Uses a Temporal Trigger to pause the workflow.
|
|
640
|
+
* - Uses a Temporal Signal to resume the workflow when the API responds.
|
|
641
|
+
*
|
|
642
|
+
* @param params - Parameters object
|
|
643
|
+
* @param params.url - Request URL
|
|
644
|
+
* @param params.payload - Request payload
|
|
645
|
+
* @param params.headers - Headers for the request
|
|
646
|
+
* @returns Resolves with the payload received by the webhook
|
|
647
|
+
*/
|
|
648
|
+
export declare function sendPostRequestAndAwaitWebhook( params: {
|
|
649
|
+
url: string;
|
|
650
|
+
payload?: object;
|
|
651
|
+
headers?: Record<string, string>;
|
|
652
|
+
} ): Promise<unknown>;
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Send an HTTP request to a URL.
|
|
584
656
|
*
|
|
585
|
-
*
|
|
586
|
-
* resume signal to continue and return the response payload.
|
|
657
|
+
* For POST or PUT requests, an optional payload can be sent as the body.
|
|
587
658
|
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
659
|
+
* The "Content-Type" is inferred from the payload type and can be overridden via the `headers` argument.
|
|
660
|
+
*
|
|
661
|
+
* If the body is not a type natively accepted by the Fetch API, it is serialized to a string: `JSON.stringify()` for objects, or `String()` for primitives.
|
|
662
|
+
*
|
|
663
|
+
* @remarks
|
|
664
|
+
* - Intended for use within workflow functions; do not use in steps or evaluators.
|
|
665
|
+
* - Steps and evaluators are activity-based and can perform HTTP requests directly.
|
|
666
|
+
* - If used within steps or evaluators, a compilation error will be raised.
|
|
667
|
+
* - Uses a Temporal Activity to dispatch the HTTP request, working around the runtime limitation for workflows.
|
|
668
|
+
*
|
|
669
|
+
* @param params - Parameters object
|
|
670
|
+
* @param params.url - Request URL
|
|
671
|
+
* @param params.method - The HTTP method (default: 'GET')
|
|
672
|
+
* @param params.payload - Request payload (only for POST/PUT)
|
|
673
|
+
* @param params.headers - Headers for the request
|
|
674
|
+
* @returns Resolves with an HTTP response serialized to a plain object
|
|
592
675
|
*/
|
|
593
|
-
export declare function
|
|
676
|
+
export declare function sendHttpRequest( params: {
|
|
677
|
+
url: string;
|
|
678
|
+
method?: HttpMethod;
|
|
679
|
+
payload?: object;
|
|
680
|
+
headers?: Record<string, string>;
|
|
681
|
+
} ): Promise<SerializedFetchResponse>;
|
|
594
682
|
|
|
595
683
|
/*
|
|
596
684
|
╭─────────────╮
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { evaluator, EvaluationStringResult, EvaluationNumberResult, EvaluationBooleanResult } from './interface/evaluator.js';
|
|
2
2
|
import { step } from './interface/step.js';
|
|
3
3
|
import { workflow } from './interface/workflow.js';
|
|
4
|
-
import {
|
|
4
|
+
import { sendHttpRequest, sendPostRequestAndAwaitWebhook } from './interface/webhook.js';
|
|
5
5
|
import { FatalError, ValidationError } from './errors.js';
|
|
6
6
|
export { continueAsNew, sleep } from '@temporalio/workflow';
|
|
7
7
|
import { z } from 'zod';
|
|
@@ -15,8 +15,9 @@ export {
|
|
|
15
15
|
EvaluationNumberResult,
|
|
16
16
|
EvaluationStringResult,
|
|
17
17
|
EvaluationBooleanResult,
|
|
18
|
-
// webhook
|
|
19
|
-
|
|
18
|
+
// webhook tools
|
|
19
|
+
sendHttpRequest,
|
|
20
|
+
sendPostRequestAndAwaitWebhook,
|
|
20
21
|
// errors
|
|
21
22
|
FatalError,
|
|
22
23
|
ValidationError,
|
|
@@ -55,9 +55,11 @@ const stepAndWorkflowSchema = z.strictObject( {
|
|
|
55
55
|
|
|
56
56
|
const evaluatorSchema = stepAndWorkflowSchema.omit( { outputSchema: true } );
|
|
57
57
|
|
|
58
|
-
const
|
|
58
|
+
const httpRequestSchema = z.object( {
|
|
59
59
|
url: z.url( { protocol: /^https?$/ } ),
|
|
60
|
-
|
|
60
|
+
method: z.enum( [ 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE' ] ),
|
|
61
|
+
payload: z.any().optional(),
|
|
62
|
+
headers: z.record( z.string(), z.string() ).optional()
|
|
61
63
|
} );
|
|
62
64
|
|
|
63
65
|
const validateAgainstSchema = ( schema, args ) => {
|
|
@@ -98,11 +100,11 @@ export function validateWorkflow( args ) {
|
|
|
98
100
|
};
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
|
-
* Validate
|
|
103
|
+
* Validate request payload
|
|
102
104
|
*
|
|
103
|
-
* @param {object} args - The
|
|
105
|
+
* @param {object} args - The request arguments
|
|
104
106
|
* @throws {StaticValidationError} Throws if args are invalid
|
|
105
107
|
*/
|
|
106
|
-
export function
|
|
107
|
-
validateAgainstSchema(
|
|
108
|
+
export function validateRequestPayload( args ) {
|
|
109
|
+
validateAgainstSchema( httpRequestSchema, args );
|
|
108
110
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { validateStep, validateWorkflow,
|
|
3
|
+
import { validateStep, validateWorkflow, validateRequestPayload, validateEvaluator, StaticValidationError } from './static.js';
|
|
4
4
|
|
|
5
5
|
const validArgs = Object.freeze( {
|
|
6
6
|
name: 'valid_name',
|
|
@@ -186,28 +186,77 @@ describe( 'interface/validator', () => {
|
|
|
186
186
|
} );
|
|
187
187
|
} );
|
|
188
188
|
|
|
189
|
-
describe( 'validate
|
|
189
|
+
describe( 'validate request', () => {
|
|
190
190
|
it( 'passes with valid http url', () => {
|
|
191
|
-
expect( () =>
|
|
191
|
+
expect( () => validateRequestPayload( { url: 'http://example.com', method: 'GET' } ) ).not.toThrow();
|
|
192
192
|
} );
|
|
193
193
|
|
|
194
194
|
it( 'passes with valid https url', () => {
|
|
195
|
-
expect( () =>
|
|
195
|
+
expect( () => validateRequestPayload( { url: 'https://example.com/path?q=1', method: 'GET' } ) ).not.toThrow();
|
|
196
196
|
} );
|
|
197
197
|
|
|
198
198
|
it( 'rejects missing url', () => {
|
|
199
199
|
const error = new StaticValidationError( '✖ Invalid input: expected string, received undefined\n → at url' );
|
|
200
|
-
expect( () =>
|
|
200
|
+
expect( () => validateRequestPayload( { method: 'GET' } ) ).toThrow( error );
|
|
201
201
|
} );
|
|
202
202
|
|
|
203
203
|
it( 'rejects invalid scheme', () => {
|
|
204
204
|
const error = new StaticValidationError( '✖ Invalid URL\n → at url' );
|
|
205
|
-
expect( () =>
|
|
205
|
+
expect( () => validateRequestPayload( { url: 'ftp://example.com', method: 'GET' } ) ).toThrow( error );
|
|
206
206
|
} );
|
|
207
207
|
|
|
208
208
|
it( 'rejects malformed url', () => {
|
|
209
209
|
const error = new StaticValidationError( '✖ Invalid URL\n → at url' );
|
|
210
|
-
expect( () =>
|
|
210
|
+
expect( () => validateRequestPayload( { url: 'http:////', method: 'GET' } ) ).toThrow( error );
|
|
211
|
+
} );
|
|
212
|
+
|
|
213
|
+
it( 'rejects missing method', () => {
|
|
214
|
+
expect( () => validateRequestPayload( { url: 'https://example.com' } ) ).toThrow( StaticValidationError );
|
|
215
|
+
} );
|
|
216
|
+
|
|
217
|
+
it( 'passes with headers as string map', () => {
|
|
218
|
+
const request = {
|
|
219
|
+
url: 'https://example.com',
|
|
220
|
+
method: 'GET',
|
|
221
|
+
headers: { 'x-api-key': 'abc', accept: 'application/json' }
|
|
222
|
+
};
|
|
223
|
+
expect( () => validateRequestPayload( request ) ).not.toThrow();
|
|
224
|
+
} );
|
|
225
|
+
|
|
226
|
+
it( 'rejects non-object headers', () => {
|
|
227
|
+
const request = {
|
|
228
|
+
url: 'https://example.com',
|
|
229
|
+
method: 'GET',
|
|
230
|
+
headers: 5
|
|
231
|
+
};
|
|
232
|
+
expect( () => validateRequestPayload( request ) ).toThrow( StaticValidationError );
|
|
233
|
+
} );
|
|
234
|
+
|
|
235
|
+
it( 'rejects headers with non-string values', () => {
|
|
236
|
+
const request = {
|
|
237
|
+
url: 'https://example.com',
|
|
238
|
+
method: 'GET',
|
|
239
|
+
headers: { 'x-num': 123 }
|
|
240
|
+
};
|
|
241
|
+
expect( () => validateRequestPayload( request ) ).toThrow( StaticValidationError );
|
|
242
|
+
} );
|
|
243
|
+
|
|
244
|
+
it( 'passes with payload object', () => {
|
|
245
|
+
const request = {
|
|
246
|
+
url: 'https://example.com/api',
|
|
247
|
+
method: 'POST',
|
|
248
|
+
payload: { a: 1, b: 'two' }
|
|
249
|
+
};
|
|
250
|
+
expect( () => validateRequestPayload( request ) ).not.toThrow();
|
|
251
|
+
} );
|
|
252
|
+
|
|
253
|
+
it( 'passes with payload string', () => {
|
|
254
|
+
const request = {
|
|
255
|
+
url: 'https://example.com/upload',
|
|
256
|
+
method: 'POST',
|
|
257
|
+
payload: 'raw-body'
|
|
258
|
+
};
|
|
259
|
+
expect( () => validateRequestPayload( request ) ).not.toThrow();
|
|
211
260
|
} );
|
|
212
261
|
} );
|
|
213
262
|
} );
|
package/src/interface/webhook.js
CHANGED
|
@@ -1,31 +1,64 @@
|
|
|
1
1
|
// THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
|
|
2
|
-
import { defineSignal, setHandler, proxyActivities, workflowInfo, proxySinks, uuid4 } from '@temporalio/workflow';
|
|
3
|
-
import {
|
|
2
|
+
import { defineSignal, setHandler, proxyActivities, workflowInfo, proxySinks, uuid4, Trigger } from '@temporalio/workflow';
|
|
3
|
+
import { ACTIVITY_SEND_HTTP_REQUEST } from '#consts';
|
|
4
4
|
import { FatalError } from '#errors';
|
|
5
|
-
import {
|
|
5
|
+
import { validateRequestPayload } from './validations/static.js';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Call the internal activity to make a HTTP request and returns its response.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} parameters
|
|
11
|
+
* @param {string} url
|
|
12
|
+
* @param {string} method
|
|
13
|
+
* @param {unknown} [payload]
|
|
14
|
+
* @param {object} [headers]
|
|
15
|
+
* @returns {Promise<object>} The serialized HTTP response
|
|
16
|
+
*/
|
|
17
|
+
export async function sendHttpRequest( { url, method = 'GET', payload = undefined, headers = undefined } ) {
|
|
18
|
+
validateRequestPayload( { method, url, payload, headers } );
|
|
19
|
+
const res = await proxyActivities( {
|
|
12
20
|
startToCloseTimeout: '3m',
|
|
13
21
|
retry: {
|
|
14
22
|
initialInterval: '15s',
|
|
15
|
-
maximumAttempts:
|
|
23
|
+
maximumAttempts: 3,
|
|
16
24
|
nonRetryableErrorTypes: [ FatalError.name ]
|
|
17
25
|
}
|
|
18
|
-
} )[
|
|
26
|
+
} )[ACTIVITY_SEND_HTTP_REQUEST]( { method, url, payload, headers } );
|
|
27
|
+
return res;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Call the internal activity to make a POST request sending a payload to a given url.
|
|
32
|
+
*
|
|
33
|
+
* After the request succeeds, pause the code using Trigger and wait for a Signal to un-pause it.
|
|
34
|
+
*
|
|
35
|
+
* The signal will be sent by the API when a response is sent to its webhook url.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} parameters
|
|
38
|
+
* @param {string} url
|
|
39
|
+
* @param {unknown} [payload]
|
|
40
|
+
* @param {object} [headers]
|
|
41
|
+
* @returns {Promise<unknown>} The response received by the webhook
|
|
42
|
+
*/
|
|
43
|
+
export async function sendPostRequestAndAwaitWebhook( { url, payload = undefined, headers = undefined } ) {
|
|
44
|
+
const { workflowId } = workflowInfo();
|
|
45
|
+
const wrappedPayload = { workflowId, payload };
|
|
46
|
+
|
|
47
|
+
await sendHttpRequest( { method: 'POST', url, payload: wrappedPayload, headers } );
|
|
19
48
|
|
|
20
49
|
const sinks = await proxySinks();
|
|
50
|
+
const resumeTrigger = new Trigger();
|
|
21
51
|
const resumeSignal = defineSignal( 'resume' );
|
|
22
52
|
|
|
23
|
-
const traceId = `${workflowId}-${url}-${uuid4}`;
|
|
53
|
+
const traceId = `${workflowId}-${url}-${uuid4()}`;
|
|
24
54
|
sinks.trace.addEventStart( { id: traceId, name: 'resume', kind: 'webhook' } );
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
|
|
56
|
+
setHandler( resumeSignal, webhookPayload => {
|
|
57
|
+
if ( !resumeTrigger.resolved ) {
|
|
58
|
+
sinks.trace.addEventEnd( { id: traceId, details: webhookPayload } );
|
|
59
|
+
resumeTrigger.resolve( webhookPayload );
|
|
60
|
+
}
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
return resumeTrigger;
|
|
31
64
|
};
|
|
@@ -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 {
|
|
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
|
|
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.
|
|
13
|
-
* @param {
|
|
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
|
|
17
|
-
const
|
|
18
|
-
method
|
|
19
|
-
headers: {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
29
|
-
} catch {
|
|
30
|
-
throw new FatalError(
|
|
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.
|
|
45
|
+
console.log( '[Core.sendHttpRequest]', response.status, response.statusText );
|
|
35
46
|
|
|
36
|
-
if ( !
|
|
37
|
-
throw new FatalError(
|
|
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(
|
|
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
|
+
} );
|
package/src/tracing/index.d.ts
CHANGED
|
@@ -6,42 +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
|
|
22
|
-
* @
|
|
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:
|
|
24
|
+
addEventStart( args: { id: string; kind: string; name: string; details: unknown } ): void;
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* Record the end of an event on the default trace for the current workflow.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* Use the same id as the start phase to correlate phases.
|
|
30
30
|
*
|
|
31
|
-
* @param
|
|
32
|
-
* @param
|
|
33
|
-
* @
|
|
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:
|
|
35
|
+
addEventEnd( args: { id: string; details: unknown } ): void;
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
38
|
+
* Record an error for an event on the default trace for the current workflow.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
40
|
+
* Use the same id as the start phase to correlate phases.
|
|
41
41
|
*
|
|
42
|
-
* @param
|
|
43
|
-
* @param
|
|
44
|
-
* @
|
|
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:
|
|
46
|
+
addEventError( args: { id: string; details: unknown } ): void;
|
|
47
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 );
|