@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.
Files changed (35) hide show
  1. package/package.json +2 -1
  2. package/src/consts.js +1 -1
  3. package/src/index.d.ts +131 -12
  4. package/src/index.js +5 -4
  5. package/src/interface/validations/static.js +8 -6
  6. package/src/interface/validations/static.spec.js +56 -7
  7. package/src/interface/webhook.js +50 -17
  8. package/src/interface/webhook.spec.js +122 -0
  9. package/src/internal_activities/index.js +44 -22
  10. package/src/internal_activities/index.spec.js +99 -0
  11. package/src/tracing/index.d.ts +20 -19
  12. package/src/utils/index.d.ts +59 -14
  13. package/src/utils/utils.js +107 -0
  14. package/src/utils/utils.spec.js +203 -1
  15. package/src/worker/loader.js +3 -3
  16. package/src/worker/loader.spec.js +4 -4
  17. package/src/tracing/processors/local/temp/traces/1767713888257_continue_as_new-19b2e908-d403-438f-af77-080d43823bea.trace +0 -4
  18. package/src/tracing/processors/local/temp/traces/1767713888294_continue_as_new-19b2e908-d403-438f-af77-080d43823bea.trace +0 -6
  19. package/src/tracing/processors/local/temp/traces/1767728879418_continue_as_new-20b3caf3-bdfe-4083-9840-95b8cb669c7c.trace +0 -3
  20. package/src/tracing/processors/local/temp/traces/1767728961526_continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40.trace +0 -4
  21. package/src/tracing/processors/local/temp/traces/1767728961568_continue_as_new-5e315608-6e29-42c5-b79a-05f82b437b40.trace +0 -4
  22. package/src/tracing/processors/local/temp/traces/1767729551367_continue_as_new-33a8b9ac-2c3d-4afe-bfa3-9f07d444dfe8.trace +0 -4
  23. package/src/tracing/processors/local/temp/traces/1767729551409_continue_as_new-33a8b9ac-2c3d-4afe-bfa3-9f07d444dfe8.trace +0 -4
  24. package/src/tracing/processors/local/temp/traces/1767729584838_continue_as_new-d18f20bb-f97a-4bfa-bfc1-48dd72b9fedf.trace +0 -4
  25. package/src/tracing/processors/local/temp/traces/1767729584880_continue_as_new-d18f20bb-f97a-4bfa-bfc1-48dd72b9fedf.trace +0 -4
  26. package/src/tracing/processors/local/temp/traces/1767730300476_continue_as_new-7887230c-c45f-4393-b26c-eae7b9f095a7.trace +0 -4
  27. package/src/tracing/processors/local/temp/traces/1767801228317_continue_as_new-7887230c-c45f-4393-b26c-eae7b9f095a7.trace +0 -4
  28. package/src/tracing/processors/local/temp/traces/1767801231585_continue_as_new-717fd020-1b29-411a-9a8c-974539c362af.trace +0 -4
  29. package/src/tracing/processors/local/temp/traces/1767801231616_continue_as_new-717fd020-1b29-411a-9a8c-974539c362af.trace +0 -4
  30. package/src/tracing/processors/local/temp/traces/1767801234199_continue_as_new-3c43d860-c2e0-4a04-808b-2676bf896426.trace +0 -4
  31. package/src/tracing/processors/local/temp/traces/1767801234233_continue_as_new-3c43d860-c2e0-4a04-808b-2676bf896426.trace +0 -4
  32. package/src/tracing/processors/local/temp/traces/1767891175397_continue_as_new-ee44babc-7690-4488-a24d-3ff258591bee.trace +0 -4
  33. package/src/tracing/processors/local/temp/traces/1767891175445_continue_as_new-ee44babc-7690-4488-a24d-3ff258591bee.trace +0 -4
  34. package/src/worker/temp/__activity_options.js +0 -12
  35. package/src/worker/temp/__workflows_entrypoint.js +0 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.1.18-dev.pr32-foo",
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 ACTIVITY_SEND_WEBHOOK = '__internal#sendWebhook';
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,11 +1,46 @@
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.
6
7
  */
7
8
  export { z } from 'zod';
8
9
 
10
+ /**
11
+ * Exports Temporal's sleep() function for advanced use cases.
12
+ * Pause workflow execution for a specified duration.
13
+ *
14
+ * Use this for delay-based throttling when calling external APIs.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { sleep } from '@output.ai/core';
19
+ *
20
+ * for ( const url of urls ) {
21
+ * await fetchUrl( url );
22
+ * await sleep( 100 ); // 100ms delay between calls
23
+ * }
24
+ * ```
25
+ *
26
+ * @see {@link https://docs.temporal.io/develop/typescript/timers}
27
+ *
28
+ * @param ms - Duration to sleep in milliseconds (or a string like '1s', '100ms')
29
+ * @returns A promise that resolves after the specified duration
30
+ *
31
+ */
32
+ export function sleep( ms: number | string ): Promise<void>;
33
+
34
+ /**
35
+ * Continue the workflow as a new execution with fresh history.
36
+ *
37
+ * Re-exported from Temporal for advanced use cases. Prefer using
38
+ * `context.control.continueAsNew()` within workflows for type-safe usage.
39
+ *
40
+ * @see {@link https://docs.temporal.io/develop/typescript/continue-as-new}
41
+ */
42
+ export { continueAsNew } from '@temporalio/workflow';
43
+
9
44
  /*
10
45
  ╭─────────────────────────╮
11
46
  │ C O M M O N T Y P E S │╮
@@ -18,6 +53,11 @@ export { z } from 'zod';
18
53
  */
19
54
  export type AnyZodSchema = z.ZodType<any, any, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
20
55
 
56
+ /**
57
+ * Allowed HTTP methods for request helpers.
58
+ */
59
+ export type HttpMethod = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
60
+
21
61
  /**
22
62
  * Native Temporal configurations for activities.
23
63
  *
@@ -132,8 +172,11 @@ export type StepFunctionWrapper<StepFunction> =
132
172
  * - Never call another step from within a step.
133
173
  * - Never call a workflow from within a step.
134
174
  *
135
- * @typeParam InputSchema - Zod schema for the step input
136
- * @typeParam OutputSchema - Zod schema for the step output
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}
137
180
  *
138
181
  * @param params - Step parameters
139
182
  * @param params.name - Human-readable step name (must start with a letter or underscore, followed by letters, numbers, or underscores)
@@ -371,6 +414,11 @@ export type WorkflowFunctionWrapper<WorkflowFunction> =
371
414
  * }
372
415
  * } )
373
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}
374
422
  *
375
423
  * @param params - Workflow parameters
376
424
  * @param params.name - Human-readable workflow name (must start with a letter or underscore, followed by letters, numbers, or underscores).
@@ -522,6 +570,12 @@ export type EvaluatorFunctionWrapper<EvaluatorFunction> =
522
570
  *
523
571
  * It is translated to a Temporal Activity.
524
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
+ *
525
579
  * @param params - Evaluator parameters
526
580
  * @param params.name - Human-readable evaluator name (must start with a letter or underscore, followed by letters, numbers, or underscores)
527
581
  * @param params.description - Description of the evaluator
@@ -549,17 +603,82 @@ export declare function evaluator<
549
603
  */
550
604
 
551
605
  /**
552
- * Create a webhook call that pauses the workflow until resumed via signal.
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.
553
609
  *
554
- * Sends a request via an activity; the workflow will await a corresponding
555
- * resume signal to continue and return the response payload.
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
+ * ```
556
620
  *
557
- * @param params - Webhook request parameters
558
- * @param params.url - Webhook request URL (POST)
559
- * @param params.payload - Webhook request payload
560
- * @returns Resolves with the response payload when resumed
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.
656
+ *
657
+ * For POST or PUT requests, an optional payload can be sent as the body.
658
+ *
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
561
675
  */
562
- export declare function createWebhook( params: { url: string; payload?: object } ): Promise<object>;
676
+ export declare function sendHttpRequest( params: {
677
+ url: string;
678
+ method?: HttpMethod;
679
+ payload?: object;
680
+ headers?: Record<string, string>;
681
+ } ): Promise<SerializedFetchResponse>;
563
682
 
564
683
  /*
565
684
  ╭─────────────╮
@@ -573,7 +692,7 @@ export declare function createWebhook( params: { url: string; payload?: object }
573
692
  *
574
693
  * Throw this error to end the workflow execution altogether without retries.
575
694
  */
576
- export class FatalError extends Error {}
695
+ export class FatalError extends Error { }
577
696
 
578
697
  /**
579
698
  * Error indicating invalid input or schema validation issues.
@@ -582,4 +701,4 @@ export class FatalError extends Error {}
582
701
  *
583
702
  * It will end the workflow execution without retries.
584
703
  */
585
- export class ValidationError extends Error {}
704
+ export class ValidationError extends Error { }
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
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 { createWebhook } from './interface/webhook.js';
4
+ import { sendHttpRequest, sendPostRequestAndAwaitWebhook } from './interface/webhook.js';
5
5
  import { FatalError, ValidationError } from './errors.js';
6
- export { continueAsNew } from '@temporalio/workflow';
6
+ export { continueAsNew, sleep } from '@temporalio/workflow';
7
7
  import { z } from 'zod';
8
8
 
9
9
  export {
@@ -15,8 +15,9 @@ export {
15
15
  EvaluationNumberResult,
16
16
  EvaluationStringResult,
17
17
  EvaluationBooleanResult,
18
- // webhook tool
19
- createWebhook,
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 webhookSchema = z.object( {
58
+ const httpRequestSchema = z.object( {
59
59
  url: z.url( { protocol: /^https?$/ } ),
60
- payload: z.any().optional()
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 createWebhook payload
103
+ * Validate request payload
102
104
  *
103
- * @param {object} args - The createWebhook arguments
105
+ * @param {object} args - The request arguments
104
106
  * @throws {StaticValidationError} Throws if args are invalid
105
107
  */
106
- export function validateCreateWebhook( args ) {
107
- validateAgainstSchema( webhookSchema, args );
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, validateCreateWebhook, validateEvaluator, StaticValidationError } from './static.js';
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 webhook', () => {
189
+ describe( 'validate request', () => {
190
190
  it( 'passes with valid http url', () => {
191
- expect( () => validateCreateWebhook( { url: 'http://example.com' } ) ).not.toThrow();
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( () => validateCreateWebhook( { url: 'https://example.com/path?q=1' } ) ).not.toThrow();
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( () => validateCreateWebhook( { } ) ).toThrow( error );
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( () => validateCreateWebhook( { url: 'ftp://example.com' } ) ).toThrow( error );
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( () => validateCreateWebhook( { url: 'http:////' } ) ).toThrow( error );
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
  } );
@@ -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 { ACTIVITY_SEND_WEBHOOK } from '#consts';
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 { validateCreateWebhook } from './validations/static.js';
5
+ import { validateRequestPayload } from './validations/static.js';
6
6
 
7
- export async function createWebhook( { url, payload } ) {
8
- validateCreateWebhook( { url, payload } );
9
- const { workflowId } = workflowInfo();
10
-
11
- await proxyActivities( {
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: 5,
23
+ maximumAttempts: 3,
16
24
  nonRetryableErrorTypes: [ FatalError.name ]
17
25
  }
18
- } )[ACTIVITY_SEND_WEBHOOK]( { url, workflowId, payload } );
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
- return new Promise( resolve =>
26
- setHandler( resumeSignal, responsePayload => {
27
- sinks.trace.addEventEnd( { id: traceId, details: responsePayload } );
28
- resolve( responsePayload );
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 { setMetadata, isStringboolTrue } from '#utils';
2
+ import { fetch } from 'undici';
3
+ import { setMetadata, isStringboolTrue, serializeFetchResponse, serializeBodyAndInferContentType } from '#utils';
3
4
  import { ComponentType } from '#consts';
4
5
  import * as localProcessor from '../tracing/processors/local/index.js';
5
6
  import * as s3Processor from '../tracing/processors/s3/index.js';
6
7
 
7
8
  /**
8
- * Send a post to a given URL
9
+ * Send a HTTP request.
9
10
  *
10
11
  * @param {object} options
11
12
  * @param {string} options.url - The target url
12
- * @param {string} options.workflowId - The current workflow id
13
- * @param {any} options.payload - The payload to send url
13
+ * @param {string} options.method - The HTTP method
14
+ * @param {unknown} [options.payload] - The payload to send url
15
+ * @param {object} [options.headers] - The headers for the request
16
+ * @param {number} [options.timeout] - The timeout for the request (default 30s)
17
+ * @returns {object} The serialized HTTP response
14
18
  * @throws {FatalError}
15
19
  */
16
- export const sendWebhook = async ( { url, workflowId, payload } ) => {
17
- const request = fetch( url, {
18
- method: 'POST',
19
- headers: {
20
- 'Content-Type': 'application/json'
21
- },
22
- body: JSON.stringify( { workflowId, payload } ),
23
- signal: AbortSignal.timeout( 5000 )
24
- } );
25
-
26
- const res = await ( async () => {
20
+ export const sendHttpRequest = async ( { url, method, payload = undefined, headers = undefined, timeout = 30_000 } ) => {
21
+ const args = {
22
+ method,
23
+ headers: new Headers( headers ?? {} ),
24
+ signal: AbortSignal.timeout( timeout )
25
+ };
26
+
27
+ const methodsWithBody = [ 'DELETE', 'PATCH', 'POST', 'PUT', 'OPTIONS' ];
28
+ const hasBodyPayload = ![ undefined, null ].includes( payload );
29
+ if ( methodsWithBody.includes( method ) && hasBodyPayload ) {
30
+ const { body, contentType } = serializeBodyAndInferContentType( payload );
31
+ if ( contentType && !args.headers.has( 'content-type' ) ) {
32
+ args.headers.set( 'Content-Type', contentType );
33
+ }
34
+ Object.assign( args, { body } );
35
+ };
36
+
37
+ const response = await ( async () => {
27
38
  try {
28
- return await request;
29
- } catch {
30
- throw new FatalError( 'Webhook fail: timeout' );
39
+ return await fetch( url, args );
40
+ } catch ( e ) {
41
+ throw new FatalError( `${method} ${url} ${e.cause ?? e.message}` );
31
42
  }
32
43
  } )();
33
44
 
34
- console.log( '[Core.SendWebhook]', res.status, res.statusText );
45
+ console.log( '[Core.sendHttpRequest]', response.status, response.statusText );
35
46
 
36
- if ( !res.ok ) {
37
- throw new FatalError( `Webhook fail: ${res.status}` );
47
+ if ( !response.ok ) {
48
+ throw new FatalError( `${method} ${url} ${response.status}` );
38
49
  }
50
+
51
+ return serializeFetchResponse( response );
39
52
  };
40
53
 
41
- setMetadata( sendWebhook, { type: ComponentType.INTERNAL_STEP } );
54
+ setMetadata( sendHttpRequest, { type: ComponentType.INTERNAL_STEP } );
42
55
 
56
+ /**
57
+ * Resolve and return all possible trace destinations based on env var flags
58
+ *
59
+ * @param {Object} options
60
+ * @param {Date} startTime - Workflow startTime
61
+ * @param {string} workflowId
62
+ * @param {string} workflowName
63
+ * @returns {object} Information about enabled workflows
64
+ */
43
65
  export const getTraceDestinations = ( { startTime, workflowId, workflowName } ) => ( {
44
66
  local: isStringboolTrue( process.env.TRACE_LOCAL_ON ) ? localProcessor.getDestination( { startTime, workflowId, workflowName } ) : null,
45
67
  remote: isStringboolTrue( process.env.TRACE_REMOTE_ON ) ? s3Processor.getDestination( { startTime, workflowId, workflowName } ) : null