@outputai/core 0.8.1 → 0.8.2-dev.e78f6b4.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 +9 -10
- package/src/bus.js +1 -1
- package/src/consts.js +5 -2
- package/src/helpers/component.js +12 -0
- package/src/helpers/component.spec.js +54 -0
- package/src/helpers/fetch.js +105 -0
- package/src/helpers/fetch.spec.js +203 -0
- package/src/helpers/function.js +15 -0
- package/src/helpers/function.spec.js +48 -0
- package/src/helpers/object.js +98 -0
- package/src/helpers/object.spec.js +377 -0
- package/src/helpers/promise.js +29 -0
- package/src/helpers/promise.spec.js +35 -0
- package/src/helpers/string.js +30 -0
- package/src/helpers/string.spec.js +64 -0
- package/src/interface/evaluator.js +14 -12
- package/src/interface/evaluator.spec.js +10 -6
- package/src/interface/index.d.ts +7 -6
- package/src/interface/index.js +2 -0
- package/src/interface/logger.d.ts +53 -0
- package/src/interface/logger.js +68 -0
- package/src/interface/logger.spec.js +138 -0
- package/src/interface/step.js +15 -12
- package/src/interface/step.spec.js +10 -6
- package/src/interface/webhook.d.ts +21 -2
- package/src/interface/workflow.js +85 -78
- package/src/interface/workflow.spec.js +11 -4
- package/src/internal_activities/index.js +38 -35
- package/src/internal_activities/index.spec.js +27 -4
- package/src/logger/development.js +2 -2
- package/src/logger/development.spec.js +19 -2
- package/src/logger/production.js +1 -1
- package/src/logger/production.spec.js +24 -5
- package/src/sdk/README.md +47 -0
- package/src/sdk/helpers/component_metadata.d.ts +17 -0
- package/src/sdk/helpers/component_metadata.js +6 -0
- package/src/sdk/helpers/component_metadata.spec.js +30 -0
- package/src/sdk/helpers/index.d.ts +12 -0
- package/src/sdk/helpers/index.js +3 -0
- package/src/sdk/helpers/objects.d.ts +51 -0
- package/src/sdk/helpers/objects.js +8 -0
- package/src/sdk/helpers/objects.spec.js +16 -0
- package/src/sdk/helpers/path.d.ts +11 -0
- package/src/sdk/helpers/path.js +32 -0
- package/src/{utils/resolve_invocation_dir.spec.js → sdk/helpers/path.spec.js} +9 -9
- package/src/sdk/runtime/context.d.ts +30 -0
- package/src/sdk/runtime/context.js +15 -0
- package/src/{activity_integration → sdk/runtime}/context.spec.js +5 -5
- package/src/sdk/runtime/events.d.ts +15 -0
- package/src/sdk/runtime/events.js +18 -0
- package/src/{activity_integration → sdk/runtime}/events.spec.js +8 -9
- package/src/sdk/runtime/index.d.ts +12 -0
- package/src/sdk/runtime/index.js +3 -0
- package/src/sdk/runtime/tracing.d.ts +46 -0
- package/src/sdk/runtime/tracing.js +11 -0
- package/src/tracing/processors/s3/redis_client.spec.js +0 -6
- package/src/tracing/processors/s3/s3_client.spec.js +0 -6
- package/src/tracing/trace_engine.js +1 -1
- package/src/worker/catalog_workflow/catalog_job.js +1 -1
- package/src/worker/catalog_workflow/index.spec.js +8 -11
- package/src/worker/configs.js +1 -1
- package/src/worker/connection_monitor.js +1 -1
- package/src/worker/global_functions.js +14 -0
- package/src/worker/global_functions.spec.js +55 -0
- package/src/worker/index.js +4 -1
- package/src/worker/index.spec.js +7 -0
- package/src/worker/interceptors/activity.js +2 -2
- package/src/worker/interceptors/workflow.js +3 -3
- package/src/worker/interceptors/workflow.spec.js +1 -1
- package/src/worker/loader/matchers.js +1 -1
- package/src/worker/loader/tools.js +1 -1
- package/src/worker/loader/tools.spec.js +1 -1
- package/src/worker/log_hooks.js +14 -0
- package/src/worker/log_hooks.spec.js +83 -2
- package/src/worker/sinks.js +7 -1
- package/src/worker/sinks.spec.js +203 -0
- package/src/activity_integration/context.d.ts +0 -23
- package/src/activity_integration/context.js +0 -18
- package/src/activity_integration/event_id_integration.spec.js +0 -52
- package/src/activity_integration/events.d.ts +0 -10
- package/src/activity_integration/events.js +0 -15
- package/src/activity_integration/index.d.ts +0 -9
- package/src/activity_integration/index.js +0 -3
- package/src/activity_integration/tracing.d.ts +0 -40
- package/src/activity_integration/tracing.js +0 -48
- package/src/utils/index.d.ts +0 -180
- package/src/utils/index.js +0 -2
- package/src/utils/resolve_invocation_dir.js +0 -34
- package/src/utils/utils.js +0 -334
- package/src/utils/utils.spec.js +0 -723
- /package/src/{internal_utils → helpers}/aggregations.js +0 -0
- /package/src/{internal_utils → helpers}/aggregations.spec.js +0 -0
- /package/src/{internal_utils → helpers}/errors.js +0 -0
- /package/src/{internal_utils → helpers}/errors.spec.js +0 -0
- /package/src/{internal_utils → helpers}/temporal_context.js +0 -0
- /package/src/{internal_utils → helpers}/temporal_context.spec.ts +0 -0
- /package/src/{internal_utils → helpers}/trace_info.js +0 -0
- /package/src/{internal_utils → helpers}/trace_info.spec.js +0 -0
- /package/src/{internal_utils → helpers}/workflow_context.js +0 -0
- /package/src/{internal_utils → helpers}/workflow_context.spec.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2-dev.e78f6b4.0",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"types": "./src/hooks/index.d.ts",
|
|
13
13
|
"import": "./src/hooks/index.js"
|
|
14
14
|
},
|
|
15
|
-
"./
|
|
16
|
-
"types": "./src/
|
|
17
|
-
"import": "./src/
|
|
15
|
+
"./sdk/helpers": {
|
|
16
|
+
"types": "./src/sdk/helpers/index.d.ts",
|
|
17
|
+
"import": "./src/sdk/helpers/index.js"
|
|
18
18
|
},
|
|
19
|
-
"./
|
|
20
|
-
"types": "./src/
|
|
21
|
-
"import": "./src/
|
|
19
|
+
"./sdk/runtime": {
|
|
20
|
+
"types": "./src/sdk/runtime/index.d.ts",
|
|
21
|
+
"import": "./src/sdk/runtime/index.js"
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"json-stream-stringify": "3.1.6",
|
|
49
49
|
"redis": "5.12.1",
|
|
50
50
|
"stacktrace-parser": "0.1.11",
|
|
51
|
-
"undici": "8.
|
|
51
|
+
"undici": "8.5.0",
|
|
52
52
|
"winston": "3.19.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
@@ -66,8 +66,7 @@
|
|
|
66
66
|
"#consts": "./src/consts.js",
|
|
67
67
|
"#errors": "./src/errors.js",
|
|
68
68
|
"#logger": "./src/logger/index.js",
|
|
69
|
-
"#
|
|
70
|
-
"#internal_utils/*": "./src/internal_utils/*.js",
|
|
69
|
+
"#helpers/*": "./src/helpers/*.js",
|
|
71
70
|
"#tracing": "./src/tracing/internal_interface.js",
|
|
72
71
|
"#trace_attribute": "./src/tracing/trace_attribute.js",
|
|
73
72
|
"#async_storage": "./src/async_storage.js",
|
package/src/bus.js
CHANGED
package/src/consts.js
CHANGED
|
@@ -2,6 +2,7 @@ export const ACTIVITY_GET_TRACE_DESTINATIONS = '__internal#getTraceDestinations'
|
|
|
2
2
|
export const ACTIVITY_OPTIONS_FILENAME = '__activity_options.js';
|
|
3
3
|
export const ACTIVITY_SEND_HTTP_REQUEST = '__internal#sendHttpRequest';
|
|
4
4
|
export const ACTIVITY_WRAPPER_VERSION_FIELD = '__output_activity_wrapper_version';
|
|
5
|
+
export const ACTIVITY_LOGGER_SYMBOL = Symbol.for( '__activity_logger' );
|
|
5
6
|
export const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
|
|
6
7
|
export const SHARED_STEP_PREFIX = '$shared';
|
|
7
8
|
export const WORKFLOW_CATALOG = '$catalog';
|
|
@@ -24,13 +25,15 @@ export const LifecycleEvent = {
|
|
|
24
25
|
export const BusEventType = {
|
|
25
26
|
WORKER_BEFORE_START: 'worker:before_start',
|
|
26
27
|
|
|
27
|
-
WORKFLOW_START: 'workflow:start',
|
|
28
28
|
WORKFLOW_END: 'workflow:end',
|
|
29
29
|
WORKFLOW_ERROR: 'workflow:error',
|
|
30
|
+
WORKFLOW_LOG: 'workflow:log',
|
|
31
|
+
WORKFLOW_START: 'workflow:start',
|
|
30
32
|
|
|
31
|
-
ACTIVITY_START: 'activity:start',
|
|
32
33
|
ACTIVITY_END: 'activity:end',
|
|
33
34
|
ACTIVITY_ERROR: 'activity:error',
|
|
35
|
+
ACTIVITY_LOG: 'activity:log',
|
|
36
|
+
ACTIVITY_START: 'activity:start',
|
|
34
37
|
|
|
35
38
|
RUNTIME_ERROR: 'runtime_error'
|
|
36
39
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ComponentType, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
2
|
+
import { assignImmutableProperty } from './object.js';
|
|
3
|
+
|
|
4
|
+
const createComponent = ( { handler, ...metadata } ) => {
|
|
5
|
+
assignImmutableProperty( handler, METADATA_ACCESS_SYMBOL, metadata );
|
|
6
|
+
return handler;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const createStep = options => createComponent( { ...options, type: ComponentType.STEP } );
|
|
10
|
+
export const createInternalStep = options => createComponent( { ...options, type: ComponentType.INTERNAL_STEP } );
|
|
11
|
+
export const createEvaluator = options => createComponent( { ...options, type: ComponentType.EVALUATOR } );
|
|
12
|
+
export const createWorkflow = options => createComponent( { ...options, type: ComponentType.WORKFLOW } );
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ComponentType, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
3
|
+
import { createEvaluator, createInternalStep, createStep, createWorkflow } from './component.js';
|
|
4
|
+
|
|
5
|
+
const factories = [
|
|
6
|
+
[ 'createStep', createStep, ComponentType.STEP ],
|
|
7
|
+
[ 'createInternalStep', createInternalStep, ComponentType.INTERNAL_STEP ],
|
|
8
|
+
[ 'createEvaluator', createEvaluator, ComponentType.EVALUATOR ],
|
|
9
|
+
[ 'createWorkflow', createWorkflow, ComponentType.WORKFLOW ]
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe( 'component helpers', () => {
|
|
13
|
+
it.each( factories )( '%s returns the handler with typed metadata', ( _, create, type ) => {
|
|
14
|
+
const handler = () => 'ok';
|
|
15
|
+
const inputSchema = { safeParse: () => ( { success: true } ) };
|
|
16
|
+
const outputSchema = { safeParse: () => ( { success: true } ) };
|
|
17
|
+
const options = { activityOptions: { startToCloseTimeout: '1m' } };
|
|
18
|
+
|
|
19
|
+
const component = create( {
|
|
20
|
+
name: 'test_component',
|
|
21
|
+
description: 'Test component',
|
|
22
|
+
inputSchema,
|
|
23
|
+
outputSchema,
|
|
24
|
+
handler,
|
|
25
|
+
options
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
expect( component ).toBe( handler );
|
|
29
|
+
expect( component[METADATA_ACCESS_SYMBOL] ).toEqual( {
|
|
30
|
+
name: 'test_component',
|
|
31
|
+
description: 'Test component',
|
|
32
|
+
inputSchema,
|
|
33
|
+
outputSchema,
|
|
34
|
+
options,
|
|
35
|
+
type
|
|
36
|
+
} );
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
it( 'defines metadata as a hidden immutable property', () => {
|
|
40
|
+
const handler = () => {};
|
|
41
|
+
const component = createStep( { name: 'hidden_step', handler } );
|
|
42
|
+
|
|
43
|
+
expect( Object.getOwnPropertyDescriptor( component, METADATA_ACCESS_SYMBOL ) ).toEqual( {
|
|
44
|
+
value: { name: 'hidden_step', type: ComponentType.STEP },
|
|
45
|
+
writable: false,
|
|
46
|
+
configurable: false,
|
|
47
|
+
enumerable: false
|
|
48
|
+
} );
|
|
49
|
+
expect( Object.keys( component ) ).toEqual( [] );
|
|
50
|
+
expect( () => {
|
|
51
|
+
component[METADATA_ACCESS_SYMBOL] = { name: 'updated' };
|
|
52
|
+
} ).toThrow( TypeError );
|
|
53
|
+
} );
|
|
54
|
+
} );
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consume Fetch's HTTP Response and return a serialized version of it;
|
|
3
|
+
*
|
|
4
|
+
* @param {Response} response
|
|
5
|
+
* @returns {object} Serialized response
|
|
6
|
+
*/
|
|
7
|
+
export const serializeFetchResponse = async response => {
|
|
8
|
+
const headers = Object.fromEntries( response.headers );
|
|
9
|
+
const contentType = headers['content-type'] || '';
|
|
10
|
+
|
|
11
|
+
const body = await ( async () => {
|
|
12
|
+
if ( contentType.includes( 'application/json' ) ) {
|
|
13
|
+
return response.json();
|
|
14
|
+
}
|
|
15
|
+
if ( contentType.startsWith( 'text/' ) ) {
|
|
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
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Duck-typing to detect a Node Readable (Stream) without importing anything
|
|
32
|
+
*
|
|
33
|
+
* @param {unknown} v
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
const isReadable = v =>
|
|
37
|
+
typeof v === 'object' &&
|
|
38
|
+
typeof v?.read === 'function' &&
|
|
39
|
+
typeof v?.on === 'function' &&
|
|
40
|
+
typeof v?.pipe === 'function' &&
|
|
41
|
+
v?.readable !== false;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 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.
|
|
45
|
+
*
|
|
46
|
+
* Non serializable types versus Content-Type reference (for Node)
|
|
47
|
+
*
|
|
48
|
+
* |Type|Is self-describing)|Inferred type by fetch|Defined mime type|
|
|
49
|
+
* |-|-|-|-}
|
|
50
|
+
* |Blob|yes|`blob.type`||
|
|
51
|
+
* |File|yes|`file.type`||
|
|
52
|
+
* |FormData|yes|"multipart/form-data; boundary=..."||
|
|
53
|
+
* |URLSearchParams|yes|"application/x-www-form-urlencoded;charset=UTF-8"||
|
|
54
|
+
* |ArrayBuffer|no||"application/octet-stream"|
|
|
55
|
+
* |TypedArray (Uint8Array,Uint16Array)||"application/octet-stream"||
|
|
56
|
+
* |DataView|no||"application/octet-stream"|
|
|
57
|
+
* |ReadableStream, Readable, AsyncIterator|no||Can't, because stream must be read|
|
|
58
|
+
*
|
|
59
|
+
* If payload is none of the above types, test it:
|
|
60
|
+
* If the it is an object, serialize using JSON.stringify and set content-type to `application/json`;
|
|
61
|
+
* Else, it is a JS primitive, serialize using JSON.stringify and set content-type to `text/plain`;
|
|
62
|
+
*
|
|
63
|
+
* This implementation is overkill for temporal workflows since the only types available there will be:
|
|
64
|
+
* - URLSearchParams
|
|
65
|
+
* - ArrayBuffer
|
|
66
|
+
* - TypedArrays
|
|
67
|
+
* - DataView
|
|
68
|
+
* - asyncGenerator
|
|
69
|
+
* The others are non deterministic and are not available at runtime, but this function was build to be flexible
|
|
70
|
+
*
|
|
71
|
+
* @see {@link https://fetch.spec.whatwg.org/#bodyinit}
|
|
72
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch}
|
|
73
|
+
*
|
|
74
|
+
* @param {unknown} payload
|
|
75
|
+
* @returns {object} An object with the serialized body and inferred content-type
|
|
76
|
+
*/
|
|
77
|
+
export const serializeBodyAndInferContentType = payload => {
|
|
78
|
+
const dataTypes = [ Blob, File, URLSearchParams, FormData ];
|
|
79
|
+
|
|
80
|
+
// empty body
|
|
81
|
+
if ( [ null, undefined ].includes( payload ) ) {
|
|
82
|
+
return { body: undefined, contentType: undefined };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Buffer types, covers ArrayBuffer, TypedArrays and DataView
|
|
86
|
+
if ( payload instanceof ArrayBuffer || ArrayBuffer.isView( payload ) ) {
|
|
87
|
+
return { body: payload, contentType: 'application/octet-stream' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// These data types auto assigned mime types
|
|
91
|
+
if ( dataTypes.some( t => payload instanceof t ) ) {
|
|
92
|
+
return { body: payload, contentType: undefined };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ReadableStream, Readable and Async Iterator mimes cant be determined without reading it
|
|
96
|
+
if ( payload instanceof ReadableStream || typeof payload[Symbol.asyncIterator] === 'function' || isReadable( payload ) ) {
|
|
97
|
+
return { body: payload, contentType: undefined };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ( typeof payload === 'object' ) {
|
|
101
|
+
return { body: JSON.stringify( payload ), contentType: 'application/json; charset=UTF-8' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { body: String( payload ), contentType: 'text/plain; charset=UTF-8' };
|
|
105
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { serializeBodyAndInferContentType, serializeFetchResponse } from './fetch.js';
|
|
4
|
+
|
|
5
|
+
describe( 'serializeFetchResponse', () => {
|
|
6
|
+
it( 'serializes JSON response body and flattens headers', async () => {
|
|
7
|
+
const payload = { a: 1, b: 'two' };
|
|
8
|
+
const response = new Response( JSON.stringify( payload ), {
|
|
9
|
+
status: 200,
|
|
10
|
+
statusText: 'OK',
|
|
11
|
+
headers: { 'content-type': 'application/json' }
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
const result = await serializeFetchResponse( response );
|
|
15
|
+
expect( result.status ).toBe( 200 );
|
|
16
|
+
expect( result.ok ).toBe( true );
|
|
17
|
+
expect( result.statusText ).toBe( 'OK' );
|
|
18
|
+
expect( result.headers['content-type'] ).toContain( 'application/json' );
|
|
19
|
+
expect( result.body ).toEqual( payload );
|
|
20
|
+
} );
|
|
21
|
+
|
|
22
|
+
it( 'serializes text/* response via text()', async () => {
|
|
23
|
+
const bodyText = 'hello world';
|
|
24
|
+
const response = new Response( bodyText, {
|
|
25
|
+
status: 201,
|
|
26
|
+
statusText: 'Created',
|
|
27
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
const result = await serializeFetchResponse( response );
|
|
31
|
+
expect( result.status ).toBe( 201 );
|
|
32
|
+
expect( result.ok ).toBe( true );
|
|
33
|
+
expect( result.statusText ).toBe( 'Created' );
|
|
34
|
+
expect( result.headers['content-type'] ).toContain( 'text/plain' );
|
|
35
|
+
expect( result.body ).toBe( bodyText );
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
if ( typeof ReadableStream !== 'undefined' ) {
|
|
39
|
+
it( 'serializes ReadableStream body for text/* via text()', async () => {
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
const chunk = encoder.encode( 'streamed text' );
|
|
42
|
+
const stream = new ReadableStream( {
|
|
43
|
+
start( controller ) {
|
|
44
|
+
controller.enqueue( chunk );
|
|
45
|
+
controller.close();
|
|
46
|
+
}
|
|
47
|
+
} );
|
|
48
|
+
const response = new Response( stream, {
|
|
49
|
+
status: 200,
|
|
50
|
+
statusText: 'OK',
|
|
51
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
const result = await serializeFetchResponse( response );
|
|
55
|
+
expect( result.status ).toBe( 200 );
|
|
56
|
+
expect( result.ok ).toBe( true );
|
|
57
|
+
expect( result.statusText ).toBe( 'OK' );
|
|
58
|
+
expect( result.headers['content-type'] ).toContain( 'text/plain' );
|
|
59
|
+
expect( result.body ).toBe( 'streamed text' );
|
|
60
|
+
} );
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it( 'serializes non-text/non-json response as base64 from arrayBuffer()', async () => {
|
|
64
|
+
const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
|
|
65
|
+
const response = new Response( bytes, {
|
|
66
|
+
status: 200,
|
|
67
|
+
statusText: 'OK',
|
|
68
|
+
headers: { 'content-type': 'application/octet-stream' }
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
const result = await serializeFetchResponse( response );
|
|
72
|
+
expect( result.status ).toBe( 200 );
|
|
73
|
+
expect( result.ok ).toBe( true );
|
|
74
|
+
expect( result.statusText ).toBe( 'OK' );
|
|
75
|
+
expect( result.headers['content-type'] ).toBe( 'application/octet-stream' );
|
|
76
|
+
expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
it( 'defaults to base64 when content-type header is missing', async () => {
|
|
80
|
+
const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
|
|
81
|
+
const response = new Response( bytes, { status: 200 } );
|
|
82
|
+
// No headers set; content-type resolves to ''
|
|
83
|
+
|
|
84
|
+
const result = await serializeFetchResponse( response );
|
|
85
|
+
expect( result.headers['content-type'] ?? '' ).toBe( '' );
|
|
86
|
+
expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
|
|
87
|
+
} );
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
describe( 'serializeBodyAndInferContentType', () => {
|
|
91
|
+
it( 'returns undefineds for null payload', () => {
|
|
92
|
+
const { body, contentType } = serializeBodyAndInferContentType( null );
|
|
93
|
+
expect( body ).toBeUndefined();
|
|
94
|
+
expect( contentType ).toBeUndefined();
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
it( 'returns undefineds for undefined payload', () => {
|
|
98
|
+
const { body, contentType } = serializeBodyAndInferContentType( undefined );
|
|
99
|
+
expect( body ).toBeUndefined();
|
|
100
|
+
expect( contentType ).toBeUndefined();
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
it( 'handles ArrayBuffer with octet-stream', () => {
|
|
104
|
+
const buf = new ArrayBuffer( 4 );
|
|
105
|
+
const { body, contentType } = serializeBodyAndInferContentType( buf );
|
|
106
|
+
expect( body ).toBe( buf );
|
|
107
|
+
expect( contentType ).toBe( 'application/octet-stream' );
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
it( 'handles TypedArray with octet-stream', () => {
|
|
111
|
+
const view = new Uint8Array( [ 1, 2, 3 ] );
|
|
112
|
+
const { body, contentType } = serializeBodyAndInferContentType( view );
|
|
113
|
+
expect( body ).toBe( view );
|
|
114
|
+
expect( contentType ).toBe( 'application/octet-stream' );
|
|
115
|
+
} );
|
|
116
|
+
|
|
117
|
+
it( 'handles DataView with octet-stream', () => {
|
|
118
|
+
const ab = new ArrayBuffer( 2 );
|
|
119
|
+
const dv = new DataView( ab );
|
|
120
|
+
const { body, contentType } = serializeBodyAndInferContentType( dv );
|
|
121
|
+
expect( body ).toBe( dv );
|
|
122
|
+
expect( contentType ).toBe( 'application/octet-stream' );
|
|
123
|
+
} );
|
|
124
|
+
|
|
125
|
+
// Environment-provided web types
|
|
126
|
+
if ( typeof URLSearchParams !== 'undefined' ) {
|
|
127
|
+
it( 'passes through URLSearchParams without content type', () => {
|
|
128
|
+
const usp = new URLSearchParams( { a: '1', b: 'two' } );
|
|
129
|
+
const { body, contentType } = serializeBodyAndInferContentType( usp );
|
|
130
|
+
expect( body ).toBe( usp );
|
|
131
|
+
expect( contentType ).toBeUndefined();
|
|
132
|
+
} );
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if ( typeof FormData !== 'undefined' ) {
|
|
136
|
+
it( 'passes through FormData without content type', () => {
|
|
137
|
+
const fd = new FormData();
|
|
138
|
+
fd.append( 'a', '1' );
|
|
139
|
+
const { body, contentType } = serializeBodyAndInferContentType( fd );
|
|
140
|
+
expect( body ).toBe( fd );
|
|
141
|
+
expect( contentType ).toBeUndefined();
|
|
142
|
+
} );
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if ( typeof Blob !== 'undefined' ) {
|
|
146
|
+
it( 'passes through Blob without content type', () => {
|
|
147
|
+
const blob = new Blob( [ 'abc' ], { type: 'text/plain' } );
|
|
148
|
+
const { body, contentType } = serializeBodyAndInferContentType( blob );
|
|
149
|
+
expect( body ).toBe( blob );
|
|
150
|
+
expect( contentType ).toBeUndefined();
|
|
151
|
+
} );
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if ( typeof File !== 'undefined' ) {
|
|
155
|
+
it( 'passes through File without content type', () => {
|
|
156
|
+
const file = new File( [ 'abc' ], 'a.txt', { type: 'text/plain' } );
|
|
157
|
+
const { body, contentType } = serializeBodyAndInferContentType( file );
|
|
158
|
+
expect( body ).toBe( file );
|
|
159
|
+
expect( contentType ).toBeUndefined();
|
|
160
|
+
} );
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it( 'passes through async iterator without content type', () => {
|
|
164
|
+
const asyncIter = ( async function *() {
|
|
165
|
+
yield 'chunk';
|
|
166
|
+
} )();
|
|
167
|
+
const { body, contentType } = serializeBodyAndInferContentType( asyncIter );
|
|
168
|
+
expect( typeof body[Symbol.asyncIterator] ).toBe( 'function' );
|
|
169
|
+
expect( contentType ).toBeUndefined();
|
|
170
|
+
} );
|
|
171
|
+
|
|
172
|
+
it( 'passes through Node Readable without content type', () => {
|
|
173
|
+
const readable = Readable.from( [ 'a', 'b' ] );
|
|
174
|
+
const { body, contentType } = serializeBodyAndInferContentType( readable );
|
|
175
|
+
expect( body ).toBe( readable );
|
|
176
|
+
expect( contentType ).toBeUndefined();
|
|
177
|
+
} );
|
|
178
|
+
|
|
179
|
+
it( 'serializes plain object as JSON with JSON content type', () => {
|
|
180
|
+
const input = { a: 1, b: 'two' };
|
|
181
|
+
const { body, contentType } = serializeBodyAndInferContentType( input );
|
|
182
|
+
expect( body ).toBe( JSON.stringify( input ) );
|
|
183
|
+
expect( contentType ).toBe( 'application/json; charset=UTF-8' );
|
|
184
|
+
} );
|
|
185
|
+
|
|
186
|
+
it( 'serializes string primitive with text/plain content type', () => {
|
|
187
|
+
const { body, contentType } = serializeBodyAndInferContentType( 'hello' );
|
|
188
|
+
expect( body ).toBe( 'hello' );
|
|
189
|
+
expect( contentType ).toBe( 'text/plain; charset=UTF-8' );
|
|
190
|
+
} );
|
|
191
|
+
|
|
192
|
+
it( 'serializes number primitive with text/plain content type', () => {
|
|
193
|
+
const { body, contentType } = serializeBodyAndInferContentType( 42 );
|
|
194
|
+
expect( body ).toBe( '42' );
|
|
195
|
+
expect( contentType ).toBe( 'text/plain; charset=UTF-8' );
|
|
196
|
+
} );
|
|
197
|
+
|
|
198
|
+
it( 'serializes boolean primitive with text/plain content type', () => {
|
|
199
|
+
const { body, contentType } = serializeBodyAndInferContentType( true );
|
|
200
|
+
expect( body ).toBe( 'true' );
|
|
201
|
+
expect( contentType ).toBe( 'text/plain; charset=UTF-8' );
|
|
202
|
+
} );
|
|
203
|
+
} );
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a function that invokes the fn argument only once when called, further calls do nothing.
|
|
3
|
+
* @param {Function} fn
|
|
4
|
+
* @returns {Function}
|
|
5
|
+
*/
|
|
6
|
+
export const runOnce = fn => {
|
|
7
|
+
const state = { executed: false, result: undefined };
|
|
8
|
+
return ( ...args ) => {
|
|
9
|
+
if ( !state.executed ) {
|
|
10
|
+
state.executed = true;
|
|
11
|
+
return state.result = fn( ...args );
|
|
12
|
+
}
|
|
13
|
+
return state.result;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { runOnce } from './function.js';
|
|
3
|
+
|
|
4
|
+
describe( 'runOnce', () => {
|
|
5
|
+
it( 'calls the wrapped function only once', () => {
|
|
6
|
+
const fn = vi.fn();
|
|
7
|
+
const once = runOnce( fn );
|
|
8
|
+
|
|
9
|
+
once();
|
|
10
|
+
once();
|
|
11
|
+
once();
|
|
12
|
+
|
|
13
|
+
expect( fn ).toHaveBeenCalledOnce();
|
|
14
|
+
} );
|
|
15
|
+
|
|
16
|
+
it( 'passes arguments and replays the first call result', () => {
|
|
17
|
+
const fn = vi.fn( ( a, b ) => a + b );
|
|
18
|
+
const once = runOnce( fn );
|
|
19
|
+
|
|
20
|
+
expect( once( 2, 3 ) ).toBe( 5 );
|
|
21
|
+
expect( once( 4, 5 ) ).toBe( 5 );
|
|
22
|
+
expect( once( 6, 7 ) ).toBe( 5 );
|
|
23
|
+
expect( fn ).toHaveBeenCalledWith( 2, 3 );
|
|
24
|
+
expect( fn ).toHaveBeenCalledOnce();
|
|
25
|
+
} );
|
|
26
|
+
|
|
27
|
+
it( 'replays the first returned promise', () => {
|
|
28
|
+
const result = Promise.resolve( 'done' );
|
|
29
|
+
const fn = vi.fn( () => result );
|
|
30
|
+
const once = runOnce( fn );
|
|
31
|
+
|
|
32
|
+
expect( once() ).toBe( result );
|
|
33
|
+
expect( once() ).toBe( result );
|
|
34
|
+
expect( fn ).toHaveBeenCalledOnce();
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
it( 'does not retry when the first call throws', () => {
|
|
38
|
+
const error = new Error( 'boom' );
|
|
39
|
+
const fn = vi.fn( () => {
|
|
40
|
+
throw error;
|
|
41
|
+
} );
|
|
42
|
+
const once = runOnce( fn );
|
|
43
|
+
|
|
44
|
+
expect( () => once() ).toThrow( error );
|
|
45
|
+
expect( once() ).toBeUndefined();
|
|
46
|
+
expect( fn ).toHaveBeenCalledOnce();
|
|
47
|
+
} );
|
|
48
|
+
} );
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect a JS plain object.
|
|
3
|
+
*
|
|
4
|
+
* @param {unknown} v
|
|
5
|
+
* @returns {boolean}
|
|
6
|
+
*/
|
|
7
|
+
export const isPlainObject = v =>
|
|
8
|
+
typeof v === 'object' &&
|
|
9
|
+
!Array.isArray( v ) &&
|
|
10
|
+
v !== null &&
|
|
11
|
+
[ Object.prototype, null ].includes( Object.getPrototypeOf( v ) );
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Node safe clone implementation that doesn't use global structuredClone()
|
|
15
|
+
* @param {object} v
|
|
16
|
+
* @returns {object}
|
|
17
|
+
*/
|
|
18
|
+
export const clone = v => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse( JSON.stringify( v ) );
|
|
21
|
+
} catch {
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new object merging object "b" onto object "a", using a resolver function to define the value to keep.
|
|
28
|
+
* - Object "b" fields that also exists on "a" will have their value defined by the "resolver" function
|
|
29
|
+
* - Object "b" fields that don't exist on object "a" will be added;
|
|
30
|
+
* - Object "a" fields that don't exist on object "b" will be preserved;
|
|
31
|
+
*
|
|
32
|
+
* If "b" isn't an object, a new object equal to "a" is returned
|
|
33
|
+
*
|
|
34
|
+
* @param {object} a - The base object
|
|
35
|
+
* @param {object} b - The target object
|
|
36
|
+
* @param {function} resolver - A function that return the value to be kept. First argument is value a, second is value b
|
|
37
|
+
* @returns {object} A new object
|
|
38
|
+
*/
|
|
39
|
+
export const deepMergeWithResolver = ( a, b, resolver ) => {
|
|
40
|
+
if ( !isPlainObject( a ) ) {
|
|
41
|
+
throw new Error( 'Parameter "a" is not an object.' );
|
|
42
|
+
}
|
|
43
|
+
if ( !isPlainObject( b ) ) {
|
|
44
|
+
return clone( a );
|
|
45
|
+
}
|
|
46
|
+
return Object.entries( b ).reduce( ( obj, [ k, v ] ) =>
|
|
47
|
+
Object.assign( obj, {
|
|
48
|
+
[k]: ( () => {
|
|
49
|
+
if ( isPlainObject( v ) && isPlainObject( a[k] ) ) {
|
|
50
|
+
return deepMergeWithResolver( a[k], v, resolver );
|
|
51
|
+
}
|
|
52
|
+
if ( Object.hasOwn( a, k ) ) {
|
|
53
|
+
return resolver( a[k], v );
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
} )()
|
|
57
|
+
} )
|
|
58
|
+
, clone( a ) );
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a new object merging object "b" onto object "a" biased to "b":
|
|
63
|
+
* - Object "b" will overwrite fields on object "a";
|
|
64
|
+
* - Object "b" fields that don't exist on object "a" will be added;
|
|
65
|
+
* - Object "a" fields that don't exist on object "b" will be preserved;
|
|
66
|
+
*
|
|
67
|
+
* If "b" isn't an object, a new object equal to "a" is returned
|
|
68
|
+
*
|
|
69
|
+
* @param {object} a - The base object
|
|
70
|
+
* @param {object} b - The target object
|
|
71
|
+
* @returns {object} A new object
|
|
72
|
+
*/
|
|
73
|
+
export const deepMerge = ( a, b ) => deepMergeWithResolver( a, b, ( _, b ) => b );
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adds an non-writable, non-configurable and non-enumerable property to an object
|
|
77
|
+
* @param {object} obj
|
|
78
|
+
* @param {string} key
|
|
79
|
+
* @param {any} value
|
|
80
|
+
* @returns
|
|
81
|
+
*/
|
|
82
|
+
export const assignImmutableProperty = ( obj, key, value ) => Object.defineProperty( obj, key, {
|
|
83
|
+
value,
|
|
84
|
+
writable: false,
|
|
85
|
+
configurable: false,
|
|
86
|
+
enumerable: false
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Receives an array and returns a copy of it with the elements shuffled
|
|
91
|
+
*
|
|
92
|
+
* @param {array} arr
|
|
93
|
+
* @returns {array}
|
|
94
|
+
*/
|
|
95
|
+
export const shuffleArray = arr => arr
|
|
96
|
+
.map( v => ( { v, sort: Math.random() } ) )
|
|
97
|
+
.sort( ( a, b ) => a.sort - b.sort )
|
|
98
|
+
.map( ( { v } ) => v );
|