@outputai/core 0.9.3-next.5289bca.0 → 0.9.3-next.62d9754.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 +1 -1
- package/src/helpers/fetch.js +88 -23
- package/src/helpers/fetch.spec.js +159 -32
- package/src/helpers/trace_info.js +2 -5
- package/src/helpers/trace_info.spec.js +4 -6
- package/src/interface/validations/index.spec.js +8 -1
- package/src/interface/validations/schemas.js +5 -1
- package/src/interface/validations/schemas.spec.js +22 -2
- package/src/interface/webhook.d.ts +15 -2
- package/src/interface/webhook.js +6 -3
- package/src/interface/webhook.spec.js +38 -2
- package/src/interface/workflow.js +14 -28
- package/src/interface/workflow.spec.js +19 -110
- package/src/internal_activities/index.js +8 -5
- package/src/internal_activities/index.spec.js +28 -14
- package/src/tracing/trace_engine.js +8 -10
- package/src/tracing/trace_engine.spec.js +17 -22
- package/src/worker/configs.js +4 -0
- package/src/worker/configs.spec.js +21 -0
- package/src/worker/configs_tuner_schema.js +81 -0
- package/src/worker/configs_tuner_schema.spec.js +127 -0
- package/src/worker/index.js +12 -3
- package/src/worker/index.spec.js +28 -0
- package/src/worker/interceptors/activity.spec.js +1 -2
package/package.json
CHANGED
package/src/helpers/fetch.js
CHANGED
|
@@ -1,32 +1,79 @@
|
|
|
1
|
+
import { FatalError } from '#errors';
|
|
2
|
+
|
|
3
|
+
/** Matches red int "hot-red-pie", but not int "redact" */
|
|
4
|
+
const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
|
|
5
|
+
|
|
6
|
+
/** Matches red in "acquired", but not in "redact" */
|
|
7
|
+
const wordEndMatcher = term => new RegExp( `${term}(?![a-z\\d])`, 'i' );
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redacts sensitive headers
|
|
11
|
+
* @param {object} headers
|
|
12
|
+
* @returns {object} The redacted headers
|
|
13
|
+
*/
|
|
14
|
+
export const redactHeaders = headers => {
|
|
15
|
+
/** Header names that look sensitive by substring rules but are not secret material. */
|
|
16
|
+
const ignoreHeaders = new Set( [
|
|
17
|
+
'x-csrf-token',
|
|
18
|
+
'public-key-pins'
|
|
19
|
+
] );
|
|
20
|
+
|
|
21
|
+
/** * Sensitive header patterns for redaction (case-insensitive). */
|
|
22
|
+
const sensitiveHeadersPatterns = [
|
|
23
|
+
// matches headers that contain these exact words
|
|
24
|
+
wordMatcher( 'authorization' ),
|
|
25
|
+
wordMatcher( 'token' ),
|
|
26
|
+
wordMatcher( 'secret' ),
|
|
27
|
+
wordMatcher( 'password' ),
|
|
28
|
+
wordMatcher( 'pwd' ),
|
|
29
|
+
wordMatcher( 'cookie' ),
|
|
30
|
+
// matches header that contain words ending with these sequences
|
|
31
|
+
wordEndMatcher( 'key' )
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
|
|
35
|
+
const lowKey = key.toLowerCase();
|
|
36
|
+
const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
|
|
37
|
+
return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
|
|
38
|
+
}, {} );
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Consume the body of a Response according it its content-type and returns it
|
|
43
|
+
* @param {Response} response
|
|
44
|
+
* @returns {string|object|undefined|null} The response body content
|
|
45
|
+
*/
|
|
46
|
+
const consumeBody = async response => {
|
|
47
|
+
const headers = Object.fromEntries( response.headers ) ?? {};
|
|
48
|
+
const contentType = ( headers['content-type'] ?? '' ).trim().toLowerCase();
|
|
49
|
+
const jsonMatcher = /^application\/(?:json|[^;\s]+?\+json)(?:\s*;.*)?$/i;
|
|
50
|
+
if ( jsonMatcher.test( contentType ) ) {
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
|
53
|
+
if ( contentType.startsWith( 'text/' ) ) {
|
|
54
|
+
return response.text();
|
|
55
|
+
}
|
|
56
|
+
return response.arrayBuffer().then( buf => Buffer.from( buf ).toString( 'base64' ) );
|
|
57
|
+
};
|
|
58
|
+
|
|
1
59
|
/**
|
|
2
60
|
* Consume Fetch's HTTP Response and return a serialized version of it;
|
|
3
61
|
*
|
|
4
62
|
* @param {Response} response
|
|
63
|
+
* @param {options} responseOptions
|
|
64
|
+
* @param {boolean} responseOptions.includeBody - If the body must be included in the response (default false)
|
|
65
|
+
* @param {boolean} responseOptions.includeHeaders - If the redacted headers must be included in the response - headers are always redacted (default false)
|
|
5
66
|
* @returns {object} Serialized response
|
|
6
67
|
*/
|
|
7
|
-
export const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return response.text();
|
|
17
|
-
}
|
|
18
|
-
return response.arrayBuffer().then( buf => Buffer.from( buf ).toString( 'base64' ) );
|
|
19
|
-
} )();
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
url: response.url,
|
|
23
|
-
status: response.status,
|
|
24
|
-
statusText: response.statusText,
|
|
25
|
-
ok: response.ok,
|
|
26
|
-
headers,
|
|
27
|
-
body
|
|
28
|
-
};
|
|
29
|
-
};
|
|
68
|
+
export const serializeResponse = async ( response, { includeHeaders = false, includeBody = false } = {} ) => ( {
|
|
69
|
+
url: response.url,
|
|
70
|
+
status: response.status,
|
|
71
|
+
statusText: response.statusText,
|
|
72
|
+
ok: response.ok,
|
|
73
|
+
...( includeHeaders && { headers: redactHeaders( Object.fromEntries( response.headers ) ) } ),
|
|
74
|
+
...( includeBody && { body: await consumeBody( response ) } )
|
|
75
|
+
} );
|
|
76
|
+
|
|
30
77
|
/**
|
|
31
78
|
* Duck-typing to detect a Node Readable (Stream) without importing anything
|
|
32
79
|
*
|
|
@@ -103,3 +150,21 @@ export const serializeBodyAndInferContentType = payload => {
|
|
|
103
150
|
|
|
104
151
|
return { body: String( payload ), contentType: 'text/plain; charset=UTF-8' };
|
|
105
152
|
};
|
|
153
|
+
|
|
154
|
+
const getSecretFromEnv = varName => {
|
|
155
|
+
const value = process.env[varName];
|
|
156
|
+
if ( value === undefined ) {
|
|
157
|
+
throw new FatalError( `Missing environment variable "${varName}" while hydrating headers.` );
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Replaces $VAR_NAME tokens in header values
|
|
164
|
+
* @param {object} headers
|
|
165
|
+
* @returns {object} Hydrated headers
|
|
166
|
+
*/
|
|
167
|
+
export const hydrateHeaders = headers =>
|
|
168
|
+
Object.entries( headers ?? {} ).reduce( ( o, [ key, value ] ) =>
|
|
169
|
+
Object.assign( o, { [key]: ( '' + value ).replace( /\$([A-Z_][A-Z0-9_]*)/g, ( _, _var ) => getSecretFromEnv( _var ) ) } )
|
|
170
|
+
, {} );
|
|
@@ -1,9 +1,136 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { afterEach, describe, it, expect } from 'vitest';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
|
-
import { serializeBodyAndInferContentType,
|
|
3
|
+
import { hydrateHeaders, redactHeaders, serializeBodyAndInferContentType, serializeResponse } from './fetch.js';
|
|
4
4
|
|
|
5
|
-
describe( '
|
|
6
|
-
it( '
|
|
5
|
+
describe( 'redactHeaders', () => {
|
|
6
|
+
it( 'redacts sensitive header names', () => {
|
|
7
|
+
const result = redactHeaders( {
|
|
8
|
+
Authorization: 'Bearer token',
|
|
9
|
+
'X-Api-Key': 'api-key',
|
|
10
|
+
Cookie: 'session=id',
|
|
11
|
+
'x-client-secret': 'secret'
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
expect( result ).toEqual( {
|
|
15
|
+
Authorization: '[REDACTED]',
|
|
16
|
+
'X-Api-Key': '[REDACTED]',
|
|
17
|
+
Cookie: '[REDACTED]',
|
|
18
|
+
'x-client-secret': '[REDACTED]'
|
|
19
|
+
} );
|
|
20
|
+
} );
|
|
21
|
+
|
|
22
|
+
it( 'preserves non-sensitive header names and ignored false positives', () => {
|
|
23
|
+
const result = redactHeaders( {
|
|
24
|
+
Accept: 'application/json',
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'x-csrf-token': 'csrf-token',
|
|
27
|
+
'public-key-pins': 'pin'
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
expect( result ).toEqual( {
|
|
31
|
+
Accept: 'application/json',
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'x-csrf-token': 'csrf-token',
|
|
34
|
+
'public-key-pins': 'pin'
|
|
35
|
+
} );
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
it( 'handles empty headers', () => {
|
|
39
|
+
expect( redactHeaders( {} ) ).toEqual( {} );
|
|
40
|
+
} );
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
describe( 'hydrateHeaders', () => {
|
|
44
|
+
afterEach( () => {
|
|
45
|
+
delete process.env.TOKEN;
|
|
46
|
+
delete process.env.API_KEY;
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'replaces environment variable tokens in header values', () => {
|
|
50
|
+
process.env.TOKEN = 'secret-token';
|
|
51
|
+
|
|
52
|
+
expect( hydrateHeaders( {
|
|
53
|
+
Authorization: 'Bearer $TOKEN'
|
|
54
|
+
} ) ).toEqual( {
|
|
55
|
+
Authorization: 'Bearer secret-token'
|
|
56
|
+
} );
|
|
57
|
+
} );
|
|
58
|
+
|
|
59
|
+
it( 'replaces repeated and multiple environment variable tokens', () => {
|
|
60
|
+
process.env.TOKEN = 'secret-token';
|
|
61
|
+
process.env.API_KEY = 'api-key';
|
|
62
|
+
|
|
63
|
+
expect( hydrateHeaders( {
|
|
64
|
+
Authorization: 'Bearer $TOKEN',
|
|
65
|
+
'X-Composite': '$TOKEN:$API_KEY:$TOKEN'
|
|
66
|
+
} ) ).toEqual( {
|
|
67
|
+
Authorization: 'Bearer secret-token',
|
|
68
|
+
'X-Composite': 'secret-token:api-key:secret-token'
|
|
69
|
+
} );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'preserves headers without environment variable tokens', () => {
|
|
73
|
+
expect( hydrateHeaders( {
|
|
74
|
+
Accept: 'application/json',
|
|
75
|
+
'Content-Type': 'application/json'
|
|
76
|
+
} ) ).toEqual( {
|
|
77
|
+
Accept: 'application/json',
|
|
78
|
+
'Content-Type': 'application/json'
|
|
79
|
+
} );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'handles missing headers', () => {
|
|
83
|
+
expect( hydrateHeaders() ).toEqual( {} );
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
it( 'throws when an environment variable token is missing', () => {
|
|
87
|
+
expect( () => hydrateHeaders( {
|
|
88
|
+
Authorization: 'Bearer $TOKEN'
|
|
89
|
+
} ) ).toThrow( 'Missing environment variable "TOKEN" while hydrating headers.' );
|
|
90
|
+
} );
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
describe( 'serializeResponse', () => {
|
|
94
|
+
it( 'serializes response metadata by default', async () => {
|
|
95
|
+
const response = new Response( '{not json', {
|
|
96
|
+
status: 200,
|
|
97
|
+
statusText: 'OK',
|
|
98
|
+
headers: { 'content-type': 'application/json' }
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
await expect( serializeResponse( response ) ).resolves.toEqual( {
|
|
102
|
+
url: '',
|
|
103
|
+
status: 200,
|
|
104
|
+
statusText: 'OK',
|
|
105
|
+
ok: true
|
|
106
|
+
} );
|
|
107
|
+
} );
|
|
108
|
+
|
|
109
|
+
it( 'includes redacted headers when requested', async () => {
|
|
110
|
+
const response = new Response( null, {
|
|
111
|
+
status: 204,
|
|
112
|
+
statusText: 'No Content',
|
|
113
|
+
headers: {
|
|
114
|
+
authorization: 'Bearer token',
|
|
115
|
+
'content-type': 'application/json'
|
|
116
|
+
}
|
|
117
|
+
} );
|
|
118
|
+
|
|
119
|
+
const result = await serializeResponse( response, { includeHeaders: true } );
|
|
120
|
+
|
|
121
|
+
expect( result ).toEqual( {
|
|
122
|
+
url: '',
|
|
123
|
+
status: 204,
|
|
124
|
+
statusText: 'No Content',
|
|
125
|
+
ok: true,
|
|
126
|
+
headers: {
|
|
127
|
+
authorization: '[REDACTED]',
|
|
128
|
+
'content-type': 'application/json'
|
|
129
|
+
}
|
|
130
|
+
} );
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
it( 'includes JSON body when requested', async () => {
|
|
7
134
|
const payload = { a: 1, b: 'two' };
|
|
8
135
|
const response = new Response( JSON.stringify( payload ), {
|
|
9
136
|
status: 200,
|
|
@@ -11,32 +138,39 @@ describe( 'serializeFetchResponse', () => {
|
|
|
11
138
|
headers: { 'content-type': 'application/json' }
|
|
12
139
|
} );
|
|
13
140
|
|
|
14
|
-
const result = await
|
|
15
|
-
|
|
16
|
-
expect( result.
|
|
17
|
-
|
|
18
|
-
|
|
141
|
+
const result = await serializeResponse( response, { includeBody: true } );
|
|
142
|
+
|
|
143
|
+
expect( result.body ).toEqual( payload );
|
|
144
|
+
} );
|
|
145
|
+
|
|
146
|
+
it( 'includes structured syntax JSON body when requested', async () => {
|
|
147
|
+
const payload = { error: 'Invalid input' };
|
|
148
|
+
const response = new Response( JSON.stringify( payload ), {
|
|
149
|
+
status: 400,
|
|
150
|
+
statusText: 'Bad Request',
|
|
151
|
+
headers: { 'content-type': 'application/problem+json; charset=utf-8' }
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
const result = await serializeResponse( response, { includeBody: true } );
|
|
155
|
+
|
|
19
156
|
expect( result.body ).toEqual( payload );
|
|
20
157
|
} );
|
|
21
158
|
|
|
22
|
-
it( '
|
|
159
|
+
it( 'includes text body when requested', async () => {
|
|
23
160
|
const bodyText = 'hello world';
|
|
24
161
|
const response = new Response( bodyText, {
|
|
25
162
|
status: 201,
|
|
26
163
|
statusText: 'Created',
|
|
27
|
-
headers: { 'content-type': '
|
|
164
|
+
headers: { 'content-type': 'Text/Plain; charset=utf-8' }
|
|
28
165
|
} );
|
|
29
166
|
|
|
30
|
-
const result = await
|
|
31
|
-
|
|
32
|
-
expect( result.ok ).toBe( true );
|
|
33
|
-
expect( result.statusText ).toBe( 'Created' );
|
|
34
|
-
expect( result.headers['content-type'] ).toContain( 'text/plain' );
|
|
167
|
+
const result = await serializeResponse( response, { includeBody: true } );
|
|
168
|
+
|
|
35
169
|
expect( result.body ).toBe( bodyText );
|
|
36
170
|
} );
|
|
37
171
|
|
|
38
172
|
if ( typeof ReadableStream !== 'undefined' ) {
|
|
39
|
-
it( '
|
|
173
|
+
it( 'includes ReadableStream text body when requested', async () => {
|
|
40
174
|
const encoder = new TextEncoder();
|
|
41
175
|
const chunk = encoder.encode( 'streamed text' );
|
|
42
176
|
const stream = new ReadableStream( {
|
|
@@ -51,16 +185,13 @@ describe( 'serializeFetchResponse', () => {
|
|
|
51
185
|
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
52
186
|
} );
|
|
53
187
|
|
|
54
|
-
const result = await
|
|
55
|
-
|
|
56
|
-
expect( result.ok ).toBe( true );
|
|
57
|
-
expect( result.statusText ).toBe( 'OK' );
|
|
58
|
-
expect( result.headers['content-type'] ).toContain( 'text/plain' );
|
|
188
|
+
const result = await serializeResponse( response, { includeBody: true } );
|
|
189
|
+
|
|
59
190
|
expect( result.body ).toBe( 'streamed text' );
|
|
60
191
|
} );
|
|
61
192
|
}
|
|
62
193
|
|
|
63
|
-
it( '
|
|
194
|
+
it( 'includes non-text non-json response as base64 when requested', async () => {
|
|
64
195
|
const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
|
|
65
196
|
const response = new Response( bytes, {
|
|
66
197
|
status: 200,
|
|
@@ -68,21 +199,17 @@ describe( 'serializeFetchResponse', () => {
|
|
|
68
199
|
headers: { 'content-type': 'application/octet-stream' }
|
|
69
200
|
} );
|
|
70
201
|
|
|
71
|
-
const result = await
|
|
72
|
-
|
|
73
|
-
expect( result.ok ).toBe( true );
|
|
74
|
-
expect( result.statusText ).toBe( 'OK' );
|
|
75
|
-
expect( result.headers['content-type'] ).toBe( 'application/octet-stream' );
|
|
202
|
+
const result = await serializeResponse( response, { includeBody: true } );
|
|
203
|
+
|
|
76
204
|
expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
|
|
77
205
|
} );
|
|
78
206
|
|
|
79
|
-
it( 'defaults to base64 when content-type header is missing', async () => {
|
|
207
|
+
it( 'defaults to base64 body when content-type header is missing and body is requested', async () => {
|
|
80
208
|
const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
|
|
81
209
|
const response = new Response( bytes, { status: 200 } );
|
|
82
|
-
// No headers set; content-type resolves to ''
|
|
83
210
|
|
|
84
|
-
const result = await
|
|
85
|
-
|
|
211
|
+
const result = await serializeResponse( response, { includeBody: true } );
|
|
212
|
+
|
|
86
213
|
expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
|
|
87
214
|
} );
|
|
88
215
|
} );
|
|
@@ -4,18 +4,15 @@ export class TraceInfo {
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Builds the trace information propagated through workflow memo and activity headers.
|
|
7
|
-
* @param {object} options - Arguments to build trace information
|
|
8
|
-
* @param {boolean} options.disableTrace - Whether trace event emission should be disabled
|
|
9
7
|
* @returns {object} trace information
|
|
10
8
|
*/
|
|
11
|
-
static build(
|
|
9
|
+
static build() {
|
|
12
10
|
const info = inWorkflowContext() ? workflowInfo() : {};
|
|
13
11
|
return {
|
|
14
12
|
workflowId: info.workflowId,
|
|
15
13
|
workflowType: info.workflowType,
|
|
16
14
|
runId: info.runId,
|
|
17
|
-
startTime: info.startTime?.getTime()
|
|
18
|
-
disableTrace
|
|
15
|
+
startTime: info.startTime?.getTime()
|
|
19
16
|
};
|
|
20
17
|
};
|
|
21
18
|
}
|
|
@@ -23,24 +23,22 @@ describe( 'TraceInfo', () => {
|
|
|
23
23
|
startTime: new Date( '2026-06-02T09:00:00.000Z' )
|
|
24
24
|
} );
|
|
25
25
|
|
|
26
|
-
expect( TraceInfo.build(
|
|
26
|
+
expect( TraceInfo.build() ).toEqual( {
|
|
27
27
|
workflowId: 'workflow-id',
|
|
28
28
|
workflowType: 'workflow-type',
|
|
29
29
|
runId: 'run-id',
|
|
30
|
-
startTime: Date.parse( '2026-06-02T09:00:00.000Z' )
|
|
31
|
-
disableTrace: false
|
|
30
|
+
startTime: Date.parse( '2026-06-02T09:00:00.000Z' )
|
|
32
31
|
} );
|
|
33
32
|
} );
|
|
34
33
|
|
|
35
34
|
it( 'builds trace info without Temporal fields outside workflow context', () => {
|
|
36
35
|
inWorkflowContextMock.mockReturnValue( false );
|
|
37
36
|
|
|
38
|
-
expect( TraceInfo.build(
|
|
37
|
+
expect( TraceInfo.build() ).toEqual( {
|
|
39
38
|
workflowId: undefined,
|
|
40
39
|
workflowType: undefined,
|
|
41
40
|
runId: undefined,
|
|
42
|
-
startTime: undefined
|
|
43
|
-
disableTrace: true
|
|
41
|
+
startTime: undefined
|
|
44
42
|
} );
|
|
45
43
|
expect( workflowInfoMock ).not.toHaveBeenCalled();
|
|
46
44
|
} );
|
|
@@ -141,7 +141,8 @@ describe( 'interface validators', () => {
|
|
|
141
141
|
url: 'https://example.com',
|
|
142
142
|
method: 'POST',
|
|
143
143
|
payload: { ok: true },
|
|
144
|
-
headers: { authorization: 'Bearer token' }
|
|
144
|
+
headers: { authorization: 'Bearer token' },
|
|
145
|
+
responseOptions: { includeHeaders: true, includeBody: true }
|
|
145
146
|
} ) ).not.toThrow();
|
|
146
147
|
} );
|
|
147
148
|
|
|
@@ -155,6 +156,12 @@ describe( 'interface validators', () => {
|
|
|
155
156
|
url: 'https://example.com',
|
|
156
157
|
method: 'OPTIONS'
|
|
157
158
|
} ) ).toThrow( ValidationError );
|
|
159
|
+
|
|
160
|
+
expect( () => validateRequestPayload( {
|
|
161
|
+
url: 'https://example.com',
|
|
162
|
+
method: 'GET',
|
|
163
|
+
responseOptions: { includeHeaders: 'yes' }
|
|
164
|
+
} ) ).toThrow( ValidationError );
|
|
158
165
|
} );
|
|
159
166
|
} );
|
|
160
167
|
|
|
@@ -88,7 +88,11 @@ export const httpRequestSchema = z.object( {
|
|
|
88
88
|
url: z.url( { protocol: /^https?$/ } ),
|
|
89
89
|
method: z.enum( [ 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE' ] ),
|
|
90
90
|
payload: z.any().optional(),
|
|
91
|
-
headers: z.record( z.string(), z.string() ).optional()
|
|
91
|
+
headers: z.record( z.string(), z.string() ).optional(),
|
|
92
|
+
responseOptions: z.strictObject( {
|
|
93
|
+
includeHeaders: z.boolean().optional().default( false ),
|
|
94
|
+
includeBody: z.boolean().optional().default( false )
|
|
95
|
+
} ).optional()
|
|
92
96
|
} );
|
|
93
97
|
|
|
94
98
|
export const workflowInvocationOptionsSchema = z.strictObject( {
|
|
@@ -143,11 +143,26 @@ describe( 'validation schemas', () => {
|
|
|
143
143
|
url: 'https://example.com',
|
|
144
144
|
method: 'POST',
|
|
145
145
|
payload: { ok: true },
|
|
146
|
-
headers: { authorization: 'Bearer token' }
|
|
146
|
+
headers: { authorization: 'Bearer token' },
|
|
147
|
+
responseOptions: { includeHeaders: true, includeBody: true }
|
|
147
148
|
} ).success ).toBe( true );
|
|
148
149
|
} );
|
|
149
150
|
|
|
150
|
-
it( '
|
|
151
|
+
it( 'defaults omitted response options to false', () => {
|
|
152
|
+
const result = httpRequestSchema.safeParse( {
|
|
153
|
+
url: 'https://example.com',
|
|
154
|
+
method: 'GET',
|
|
155
|
+
responseOptions: {}
|
|
156
|
+
} );
|
|
157
|
+
|
|
158
|
+
expect( result.success ).toBe( true );
|
|
159
|
+
expect( result.data.responseOptions ).toEqual( {
|
|
160
|
+
includeHeaders: false,
|
|
161
|
+
includeBody: false
|
|
162
|
+
} );
|
|
163
|
+
} );
|
|
164
|
+
|
|
165
|
+
it( 'rejects invalid URL protocols, methods, header values, and response options', () => {
|
|
151
166
|
expect( httpRequestSchema.safeParse( { url: 'ftp://example.com', method: 'GET' } ).success ).toBe( false );
|
|
152
167
|
expect( httpRequestSchema.safeParse( { url: 'https://example.com', method: 'OPTIONS' } ).success ).toBe( false );
|
|
153
168
|
expect( httpRequestSchema.safeParse( {
|
|
@@ -155,6 +170,11 @@ describe( 'validation schemas', () => {
|
|
|
155
170
|
method: 'GET',
|
|
156
171
|
headers: { count: 1 }
|
|
157
172
|
} ).success ).toBe( false );
|
|
173
|
+
expect( httpRequestSchema.safeParse( {
|
|
174
|
+
url: 'https://example.com',
|
|
175
|
+
method: 'GET',
|
|
176
|
+
responseOptions: { includeBody: 'yes' }
|
|
177
|
+
} ).success ).toBe( false );
|
|
158
178
|
} );
|
|
159
179
|
} );
|
|
160
180
|
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export type HttpMethod = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Controls which response fields are included in the serialized HTTP response.
|
|
8
|
+
*/
|
|
9
|
+
export type ResponseOptions = {
|
|
10
|
+
/** Include redacted response headers in the returned value. */
|
|
11
|
+
includeHeaders?: boolean,
|
|
12
|
+
|
|
13
|
+
/** Include the response body in the returned value. */
|
|
14
|
+
includeBody?: boolean
|
|
15
|
+
};
|
|
16
|
+
|
|
6
17
|
/** Represents a {Response} serialized to plain object */
|
|
7
18
|
export type SerializedFetchResponse = {
|
|
8
19
|
/** The response url */
|
|
@@ -18,10 +29,10 @@ export type SerializedFetchResponse = {
|
|
|
18
29
|
ok: boolean,
|
|
19
30
|
|
|
20
31
|
/** Object with response headers */
|
|
21
|
-
headers
|
|
32
|
+
headers?: Record<string, string>,
|
|
22
33
|
|
|
23
34
|
/** Response body, either JSON, text, or an arrayBuffer converted to base64 */
|
|
24
|
-
body
|
|
35
|
+
body?: unknown
|
|
25
36
|
};
|
|
26
37
|
|
|
27
38
|
/**
|
|
@@ -93,6 +104,7 @@ export declare function sendPostRequestAndAwaitWebhook( params: {
|
|
|
93
104
|
* @param params.method - The HTTP method (default: 'GET')
|
|
94
105
|
* @param params.payload - Request payload (only for POST/PUT)
|
|
95
106
|
* @param params.headers - Headers for the request
|
|
107
|
+
* @param params.responseOptions - Controls which response fields are included
|
|
96
108
|
* @returns Resolves with an HTTP response serialized to a plain object
|
|
97
109
|
*/
|
|
98
110
|
export declare function sendHttpRequest( params: {
|
|
@@ -100,4 +112,5 @@ export declare function sendHttpRequest( params: {
|
|
|
100
112
|
method?: HttpMethod;
|
|
101
113
|
payload?: object;
|
|
102
114
|
headers?: Record<string, string>;
|
|
115
|
+
responseOptions?: ResponseOptions;
|
|
103
116
|
} ): Promise<SerializedFetchResponse>;
|
package/src/interface/webhook.js
CHANGED
|
@@ -12,10 +12,13 @@ import { validateRequestPayload } from './validations/index.js';
|
|
|
12
12
|
* @param {string} method
|
|
13
13
|
* @param {unknown} [payload]
|
|
14
14
|
* @param {object} [headers]
|
|
15
|
+
* @param {object} [options.responseOptions] - Response options
|
|
16
|
+
* @param {boolean} [options.responseOptions.includeHeaders] - If the response will include the headers - Headers are always redacted (default false)
|
|
17
|
+
* @param {boolean} [options.responseOptions.includeBody] - If the response will include the body (default false)
|
|
15
18
|
* @returns {Promise<object>} The serialized HTTP response
|
|
16
19
|
*/
|
|
17
|
-
export async function sendHttpRequest( { url, method = 'GET', payload = undefined, headers = undefined } ) {
|
|
18
|
-
validateRequestPayload( { method, url, payload, headers } );
|
|
20
|
+
export async function sendHttpRequest( { url, method = 'GET', payload = undefined, headers = undefined, responseOptions = {} } ) {
|
|
21
|
+
validateRequestPayload( { method, url, payload, headers, responseOptions } );
|
|
19
22
|
const res = await proxyActivities( {
|
|
20
23
|
startToCloseTimeout: '3m',
|
|
21
24
|
retry: {
|
|
@@ -23,7 +26,7 @@ export async function sendHttpRequest( { url, method = 'GET', payload = undefine
|
|
|
23
26
|
maximumAttempts: 3,
|
|
24
27
|
nonRetryableErrorTypes: [ FatalError.name ]
|
|
25
28
|
}
|
|
26
|
-
} )[ACTIVITY_SEND_HTTP_REQUEST]( { method, url, payload, headers } );
|
|
29
|
+
} )[ACTIVITY_SEND_HTTP_REQUEST]( { method, url, payload, headers, responseOptions } );
|
|
27
30
|
return res.output;
|
|
28
31
|
};
|
|
29
32
|
|
|
@@ -82,7 +82,12 @@ describe( 'interface/webhook', () => {
|
|
|
82
82
|
const res = await sendHttpRequest( args );
|
|
83
83
|
|
|
84
84
|
// validated
|
|
85
|
-
expect( validateRequestPayloadMock ).toHaveBeenCalledWith( {
|
|
85
|
+
expect( validateRequestPayloadMock ).toHaveBeenCalledWith( {
|
|
86
|
+
...args,
|
|
87
|
+
payload: undefined,
|
|
88
|
+
headers: undefined,
|
|
89
|
+
responseOptions: {}
|
|
90
|
+
} );
|
|
86
91
|
|
|
87
92
|
// activity proxied with specified options
|
|
88
93
|
expect( proxyActivitiesMock ).toHaveBeenCalledTimes( 1 );
|
|
@@ -95,10 +100,40 @@ describe( 'interface/webhook', () => {
|
|
|
95
100
|
} ) );
|
|
96
101
|
|
|
97
102
|
// activity invoked with the same args
|
|
98
|
-
expect( activityFnMock ).toHaveBeenCalledWith( {
|
|
103
|
+
expect( activityFnMock ).toHaveBeenCalledWith( {
|
|
104
|
+
...args,
|
|
105
|
+
payload: undefined,
|
|
106
|
+
headers: undefined,
|
|
107
|
+
responseOptions: {}
|
|
108
|
+
} );
|
|
99
109
|
expect( res ).toEqual( fakeSerializedResponse );
|
|
100
110
|
} );
|
|
101
111
|
|
|
112
|
+
it( 'sendHttpRequest forwards response options', async () => {
|
|
113
|
+
const { sendHttpRequest } = await import( './webhook.js' );
|
|
114
|
+
|
|
115
|
+
activityFnMock.mockResolvedValueOnce( activityEnvelope( { ok: true } ) );
|
|
116
|
+
|
|
117
|
+
const args = {
|
|
118
|
+
url: 'https://example.com/api',
|
|
119
|
+
method: 'GET',
|
|
120
|
+
responseOptions: { includeHeaders: true, includeBody: true }
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await sendHttpRequest( args );
|
|
124
|
+
|
|
125
|
+
expect( validateRequestPayloadMock ).toHaveBeenCalledWith( {
|
|
126
|
+
...args,
|
|
127
|
+
payload: undefined,
|
|
128
|
+
headers: undefined
|
|
129
|
+
} );
|
|
130
|
+
expect( activityFnMock ).toHaveBeenCalledWith( {
|
|
131
|
+
...args,
|
|
132
|
+
payload: undefined,
|
|
133
|
+
headers: undefined
|
|
134
|
+
} );
|
|
135
|
+
} );
|
|
136
|
+
|
|
102
137
|
it( 'sendPostRequestAndAwaitWebhook posts wrapped payload and resolves on resume signal', async () => {
|
|
103
138
|
const { sendPostRequestAndAwaitWebhook } = await import( './webhook.js' );
|
|
104
139
|
|
|
@@ -121,6 +156,7 @@ describe( 'interface/webhook', () => {
|
|
|
121
156
|
expect( callArgs.url ).toBe( url );
|
|
122
157
|
expect( callArgs.payload ).toEqual( { workflowId: 'wf-123', payload: { x: 1 } } );
|
|
123
158
|
expect( callArgs.headers ).toEqual( { a: 'b' } );
|
|
159
|
+
expect( callArgs.responseOptions ).toEqual( {} );
|
|
124
160
|
|
|
125
161
|
// Returns a promise (async function) for the eventual webhook result
|
|
126
162
|
expect( typeof promise.then ).toBe( 'function' );
|