diff --git a/package.json b/package.json index bfef941..12a10a8 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ ], "exports": { "./eslint": "./dist/eslint/index.js", - "./logger": "./dist/logger/index.js" + "./logger": "./dist/logger/index.js", + "./processing": "./dist/processing/index.js" }, "scripts": { "build": "tsc --project tsconfig.build.json", @@ -25,10 +26,12 @@ "prepublishOnly": "pnpm i && pnpm run clean && pnpm run build", "prettier:check": "prettier --check \"./**/*.{js,ts,md,json}\"", "prettier:fix": "prettier --write \"./**/*.{js,ts,md,json}\"", - "test": "jest --passWithNoTests", + "test": "jest", "tsc": "tsc --project ." }, "dependencies": { + "@api3/ois": "^2.2.1", + "@api3/promise-utils": "^0.4.0", "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", "eslint-config-next": "^13.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03dab11..af6806f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,12 @@ settings: excludeLinksFromLockfile: false dependencies: + '@api3/ois': + specifier: ^2.2.1 + version: 2.2.1 + '@api3/promise-utils': + specifier: ^0.4.0 + version: 0.4.0 '@typescript-eslint/eslint-plugin': specifier: ^6.2.1 version: 6.7.4(@typescript-eslint/parser@6.7.4)(eslint@8.50.0)(typescript@5.2.2) @@ -117,6 +123,17 @@ packages: '@jridgewell/gen-mapping': 0.3.3 '@jridgewell/trace-mapping': 0.3.19 + /@api3/ois@2.2.1: + resolution: {integrity: sha512-C4tSMBccDlD8NkZMVATQXOKctI46fSOlzpbZmZoFknsIdYfQvGNU49StGRJZ6fJJkwXEX1TlkRC7rY2yHEJjqw==} + dependencies: + lodash: 4.17.21 + zod: 3.22.4 + dev: false + + /@api3/promise-utils@0.4.0: + resolution: {integrity: sha512-+8fcNjjQeQAuuSXFwu8PMZcYzjwjDiGYcMUfAQ0lpREb1zHonwWZ2N0B9h/g1cvWzg9YhElbeb/SyhCrNm+b/A==} + dev: false + /@babel/code-frame@7.22.13: resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} engines: {node: '>=6.9.0'} @@ -4610,4 +4627,3 @@ packages: /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} - dev: true diff --git a/src/processing/README.md b/src/processing/README.md new file mode 100644 index 0000000..44f4e63 --- /dev/null +++ b/src/processing/README.md @@ -0,0 +1,49 @@ +# Processing + +> Implementation of [OIS processing](https://docs.api3.org/reference/ois/latest/processing.html). + +The pre/post processing is only supported for Node.js environments and uses internal Node.js modules. + +## Getting started + +1. Install `zod` which is a peer dependency of this module. Zod is used for validating the logger configuration. + +## Documentation + +The processing module exports two main functions: + + + +```ts +/** + * Pre-processes API call parameters based on the provided endpoint's processing specifications. + * + * @param endpoint The endpoint containing processing specifications. + * @param apiCallParameters The parameters to be pre-processed. + * @param processingOptions Options to control the async processing behavior like retries and timeouts. + * + * @returns A promise that resolves to the pre-processed parameters. + */ +export declare const preProcessApiCallParameters: ( + endpoint: Endpoint, + apiCallParameters: ApiCallParameters, + processingOptions?: GoAsyncOptions +) => Promise; + +/** + * Post-processes the API call response based on the provided endpoint's processing specifications. + * + * @param apiCallResponse The raw response obtained from the API call. + * @param endpoint The endpoint containing processing specifications. + * @param apiCallParameters The parameters used in the API call. + * @param processingOptions Options to control the async processing behavior like retries and timeouts. + * + * @returns A promise that resolves to the post-processed API call response. + */ +export declare const postProcessApiCallResponse: ( + apiCallResponse: unknown, + endpoint: Endpoint, + apiCallParameters: ApiCallParameters, + processingOptions?: GoAsyncOptions +) => Promise; +``` diff --git a/src/processing/index.ts b/src/processing/index.ts new file mode 100644 index 0000000..5086240 --- /dev/null +++ b/src/processing/index.ts @@ -0,0 +1,3 @@ +export * from './processing'; +export * from './schema'; +export * from './unsafe-evaluate'; diff --git a/src/processing/processing.test.ts b/src/processing/processing.test.ts new file mode 100644 index 0000000..8623690 --- /dev/null +++ b/src/processing/processing.test.ts @@ -0,0 +1,272 @@ +/* eslint-disable jest/prefer-strict-equal */ // Because the errors are thrown from the "vm" module (different context), they are not strictly equal. +import { createEndpoint } from '../../test/fixtures'; + +import { + addReservedParameters, + postProcessApiCallResponse, + preProcessApiCallParameters, + removeReservedParameters, +} from './processing'; + +describe(preProcessApiCallParameters.name, () => { + it('valid processing code', async () => { + const endpoint = createEndpoint({ + preProcessingSpecifications: [ + { + environment: 'Node', + value: 'const output = {...input, from: "ETH"};', + timeoutMs: 5000, + }, + { + environment: 'Node', + value: 'const output = {...input, newProp: "airnode"};', + timeoutMs: 5000, + }, + ], + }); + const parameters = { _type: 'int256', _path: 'price' }; + + const result = await preProcessApiCallParameters(endpoint, parameters); + + expect(result).toEqual({ + _path: 'price', + _type: 'int256', + from: 'ETH', + newProp: 'airnode', + }); + }); + + it('invalid processing code', async () => { + const endpoint = createEndpoint({ + preProcessingSpecifications: [ + { + environment: 'Node', + value: 'something invalid; const output = {...input, from: `ETH`};', + timeoutMs: 5000, + }, + { + environment: 'Node', + value: 'const output = {...input, newProp: "airnode"};', + timeoutMs: 5000, + }, + ], + }); + const parameters = { _type: 'int256', _path: 'price', from: 'TBD' }; + + const throwingFunc = async () => preProcessApiCallParameters(endpoint, parameters); + + await expect(throwingFunc).rejects.toEqual(new Error('SyntaxError: Unexpected identifier')); + }); + + it('demonstrates access to endpointParameters, but reserved parameters are inaccessible', async () => { + const parameters = { _type: 'int256', _path: 'price', to: 'USD' }; + const endpoint = createEndpoint({ + preProcessingSpecifications: [ + { + environment: 'Node', + // pretend the user is trying to 1) override _path and 2) set a new parameter based on + // the presence of the reserved parameter _type (which is inaccessible) + value: + 'const output = {...input, from: "ETH", _path: "price.newpath", myVal: input._type ? "123" : "456", newTo: endpointParameters.to };', + timeoutMs: 5000, + }, + ], + }); + + const result = await preProcessApiCallParameters(endpoint, parameters); + + expect(result).toEqual({ + _path: 'price', // is not overridden + _type: 'int256', + from: 'ETH', // originates from the processing code + to: 'USD', // should be unchanged from the original parameters + myVal: '456', // is set to "456" because _type is not present in the environment + newTo: 'USD', // demonstrates access to endpointParameters + }); + }); + + it('uses native modules for processing', async () => { + const endpoint = createEndpoint({ + preProcessingSpecifications: [ + { + environment: 'Node', + value: ` + const randomValue = crypto.randomBytes(4).toString('hex'); + const output = {...input, randomValue}; + `, + timeoutMs: 5000, + }, + ], + }); + const parameters = { _type: 'int256', _path: 'price' }; + + const result = await preProcessApiCallParameters(endpoint, parameters); + + // Check that the result contains the original parameters and a valid 8-character hex random value. + expect(result).toMatchObject({ + _path: 'price', + _type: 'int256', + }); + expect(result.randomValue).toHaveLength(8); + expect(/^[\da-f]{8}$/i.test(result.randomValue)).toBe(true); + }); + + it('throws error due to processing timeout', async () => { + const endpoint = createEndpoint({ + preProcessingSpecifications: [ + { + environment: 'Node async', + value: ` + const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + delay(5000); + const output = {...input, from: 'ETH'}; + `, + timeoutMs: 100, // This timeout is shorter than the delay in the processing code. + }, + ], + }); + const parameters = { _type: 'int256', _path: 'price' }; + + const throwingFunc = async () => preProcessApiCallParameters(endpoint, parameters); + + await expect(throwingFunc).rejects.toThrow('Timeout exceeded'); + }); +}); + +describe(postProcessApiCallResponse.name, () => { + it('processes valid code', async () => { + const parameters = { _type: 'int256', _path: 'price' }; + const endpoint = createEndpoint({ + postProcessingSpecifications: [ + { + environment: 'Node', + value: 'const output = parseInt(input.price)*2;', + timeoutMs: 5000, + }, + { + environment: 'Node', + value: 'const output = parseInt(input)*2;', + timeoutMs: 5000, + }, + ], + }); + + const result = await postProcessApiCallResponse({ price: 1000 }, endpoint, parameters); + + expect(result).toBe(4000); + }); + + it('demonstrates access to endpointParameters, but reserved parameters are inaccessible', async () => { + const myMultiplier = 10; + const parameters = { _type: 'int256', _path: 'price', myMultiplier }; + const endpoint = createEndpoint({ + postProcessingSpecifications: [ + { + environment: 'Node', + value: ` + const reservedMultiplier = endpointParameters._times ? 1 : 2; + const output = parseInt(input.price) * endpointParameters.myMultiplier * reservedMultiplier + `, + timeoutMs: 5000, + }, + ], + }); + + const price = 1000; + const result = await postProcessApiCallResponse({ price }, endpoint, parameters); + + // reserved parameters (_times) should be inaccessible to post-processing for the + // http-gateway, hence multiplication by 2 instead of 1 + expect(result).toEqual(price * myMultiplier * 2); + }); + + it('throws on invalid code', async () => { + const parameters = { _type: 'int256', _path: 'price' }; + const endpoint = createEndpoint({ + postProcessingSpecifications: [ + { + environment: 'Node', + value: 'const output = parseInt(input.price)*1000;', + timeoutMs: 5000, + }, + { + environment: 'Node', + value: ` + Something Unexpected; + const output = parseInt(input)*2; + `, + timeoutMs: 5000, + }, + ], + }); + + const throwingFunc = async () => postProcessApiCallResponse({ price: 1000 }, endpoint, parameters); + + await expect(throwingFunc).rejects.toEqual(new Error('SyntaxError: Unexpected identifier')); + }); +}); + +describe(removeReservedParameters.name, () => { + it('removes all reserved parameters', () => { + const parameters = { + normalParam1: 'value1', + _type: 'int256', + _path: 'price', + normalParam2: 'value2', + }; + + const result = removeReservedParameters(parameters); + + expect(result).toEqual({ + normalParam1: 'value1', + normalParam2: 'value2', + }); + }); + + it('returns same object if no reserved parameters found', () => { + const parameters = { + normalParam1: 'value1', + normalParam2: 'value2', + }; + + const result = removeReservedParameters(parameters); + + expect(result).toEqual(parameters); + }); +}); + +describe(addReservedParameters.name, () => { + it('adds reserved parameters from initial to modified parameters', () => { + const initialParameters = { + _type: 'int256', + _path: 'price', + }; + const modifiedParameters = { + normalParam1: 'value1', + normalParam2: 'value2', + }; + + const result = addReservedParameters(initialParameters, modifiedParameters); + + expect(result).toEqual({ + normalParam1: 'value1', + normalParam2: 'value2', + _type: 'int256', + _path: 'price', + }); + }); + + it('does not modify modifiedParameters if no reserved parameters in initialParameters', () => { + const initialParameters = { + normalParam3: 'value3', + }; + const modifiedParameters = { + normalParam1: 'value1', + normalParam2: 'value2', + }; + + const result = addReservedParameters(initialParameters, modifiedParameters); + + expect(result).toEqual(modifiedParameters); + }); +}); diff --git a/src/processing/processing.ts b/src/processing/processing.ts new file mode 100644 index 0000000..4c39450 --- /dev/null +++ b/src/processing/processing.ts @@ -0,0 +1,160 @@ +import { type Endpoint, RESERVED_PARAMETERS } from '@api3/ois'; +import { type GoAsyncOptions, go } from '@api3/promise-utils'; + +import { type ApiCallParameters, apiCallParametersSchema } from './schema'; +import { unsafeEvaluate, unsafeEvaluateAsync } from './unsafe-evaluate'; + +export const DEFAULT_PROCESSING_TIMEOUT_MS = 10_000; + +const reservedParameters = RESERVED_PARAMETERS as string[]; // To avoid strict TS checks. + +/** + * Removes reserved parameters from the parameters object. + * @param parameters The API call parameters from which reserved parameters will be removed. + * @returns The parameters object without reserved parameters. + */ +export const removeReservedParameters = (parameters: ApiCallParameters): ApiCallParameters => { + const result: ApiCallParameters = {}; + + for (const key in parameters) { + if (!reservedParameters.includes(key)) { + result[key] = parameters[key]; + } + } + + return result; +}; + +/** + * Re-inserts reserved parameters from the initial parameters object into the modified parameters object. + * @param initialParameters The initial API call parameters that might contain reserved parameters. + * @param modifiedParameters The modified API call parameters to which reserved parameters will be added. + * @returns The modified parameters object with re-inserted reserved parameters. + */ +export const addReservedParameters = ( + initialParameters: ApiCallParameters, + modifiedParameters: ApiCallParameters +): ApiCallParameters => { + for (const key in initialParameters) { + if (reservedParameters.includes(key)) { + modifiedParameters[key] = initialParameters[key]; + } + } + + return modifiedParameters; +}; + +/** + * Pre-processes API call parameters based on the provided endpoint's processing specifications. + * + * @param endpoint The endpoint containing processing specifications. + * @param apiCallParameters The parameters to be pre-processed. + * @param processingOptions Options to control the async processing behavior like retries and timeouts. + * + * @returns A promise that resolves to the pre-processed parameters. + */ +export const preProcessApiCallParameters = async ( + endpoint: Endpoint, + apiCallParameters: ApiCallParameters, + processingOptions: GoAsyncOptions = { retries: 0, totalTimeoutMs: DEFAULT_PROCESSING_TIMEOUT_MS } +): Promise => { + const { preProcessingSpecifications } = endpoint; + if (!preProcessingSpecifications || preProcessingSpecifications.length === 0) { + return apiCallParameters; + } + + // We only wrap the code through "go" utils because of the timeout and retry logic. + const goProcessedParameters = await go(async () => { + let currentValue: unknown = removeReservedParameters(apiCallParameters); + + for (const processing of preProcessingSpecifications) { + // Provide endpoint parameters without reserved parameters immutably between steps. Recompute them for each + // snippet independently because processing snippets can modify the parameters. + const endpointParameters = removeReservedParameters(apiCallParameters); + + switch (processing.environment) { + case 'Node': { + currentValue = await unsafeEvaluate( + processing.value, + { input: currentValue, endpointParameters }, + processing.timeoutMs + ); + break; + } + case 'Node async': { + currentValue = await unsafeEvaluateAsync( + processing.value, + { input: currentValue, endpointParameters }, + processing.timeoutMs + ); + break; + } + } + } + + return currentValue; + }, processingOptions); + if (!goProcessedParameters.success) throw goProcessedParameters.error; + + // Let this throw if the processed parameters are invalid. + const parsedParameters = apiCallParametersSchema.parse(goProcessedParameters.data); + + // Having removed reserved parameters for pre-processing, we need to re-insert them for the API call. + return addReservedParameters(apiCallParameters, parsedParameters); +}; + +/** + * Post-processes the API call response based on the provided endpoint's processing specifications. + * + * @param apiCallResponse The raw response obtained from the API call. + * @param endpoint The endpoint containing processing specifications. + * @param apiCallParameters The parameters used in the API call. + * @param processingOptions Options to control the async processing behavior like retries and timeouts. + * + * @returns A promise that resolves to the post-processed API call response. + */ +export const postProcessApiCallResponse = async ( + apiCallResponse: unknown, + endpoint: Endpoint, + apiCallParameters: ApiCallParameters, + processingOptions: GoAsyncOptions = { retries: 0, totalTimeoutMs: DEFAULT_PROCESSING_TIMEOUT_MS } +) => { + const { postProcessingSpecifications } = endpoint; + if (!postProcessingSpecifications || postProcessingSpecifications?.length === 0) { + return apiCallResponse; + } + + // We only wrap the code through "go" utils because of the timeout and retry logic. + const goResult = await go(async () => { + let currentValue: unknown = apiCallResponse; + + for (const processing of postProcessingSpecifications) { + // Provide endpoint parameters without reserved parameters immutably between steps. Recompute them for each + // snippet independently because processing snippets can modify the parameters. + const endpointParameters = removeReservedParameters(apiCallParameters); + switch (processing.environment) { + case 'Node': { + currentValue = await unsafeEvaluate( + processing.value, + { input: currentValue, endpointParameters }, + processing.timeoutMs + ); + break; + } + case 'Node async': { + currentValue = await unsafeEvaluateAsync( + processing.value, + { input: currentValue, endpointParameters }, + processing.timeoutMs + ); + break; + } + } + } + + return currentValue; + }, processingOptions); + if (!goResult.success) throw goResult.error; + + return goResult.data; +}; diff --git a/src/processing/schema.ts b/src/processing/schema.ts new file mode 100644 index 0000000..be3a865 --- /dev/null +++ b/src/processing/schema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const apiCallParametersSchema = z.record(z.string(), z.any()); + +export type ApiCallParameters = z.infer; diff --git a/src/processing/unsafe-evaluate.test.ts b/src/processing/unsafe-evaluate.test.ts new file mode 100644 index 0000000..da9859f --- /dev/null +++ b/src/processing/unsafe-evaluate.test.ts @@ -0,0 +1,103 @@ +/* eslint-disable jest/prefer-strict-equal */ // Because the errors are thrown from the "vm" module (different context), they are not strictly equal. +import { unsafeEvaluate, unsafeEvaluateAsync } from './unsafe-evaluate'; + +describe('unsafe evaluate - sync', () => { + it('executes harmless code', () => { + const result = unsafeEvaluate("const output = {...input, c: 'some-value'}", { input: { a: true, b: 123 } }, 5000); + + expect(result).toEqual({ a: true, b: 123, c: 'some-value' }); + }); + + it('throws on exception', () => { + expect(() => unsafeEvaluate("throw new Error('unexpected')", {}, 5000)).toThrow('unexpected'); + }); +}); + +describe('unsafe evaluate - async', () => { + it('executes harmless code', async () => { + const result = unsafeEvaluateAsync( + "const output = {...input, c: 'some-value'}; resolve(output);", + { input: { a: true, b: 123 } }, + 5000 + ); + + await expect(result).resolves.toEqual({ a: true, b: 123, c: 'some-value' }); + }); + + it('can use setTimeout and setInterval', async () => { + const result = unsafeEvaluateAsync( + ` + const fn = async () => { + const output = input; + output.push('start') + + const tickMs = 35 + const bufferMs = 25 + setInterval(() => output.push('ping interval'), tickMs) + await new Promise((res) => setTimeout(res, tickMs * 4 + bufferMs)); + + output.push('end') + resolve(output); + }; + + fn() + `, + { input: [] }, + 200 + ); + + await expect(result).resolves.toEqual([ + 'start', + 'ping interval', + 'ping interval', + 'ping interval', + 'ping interval', + 'end', + ]); + }); + + it('applies timeout when using setTimeout', async () => { + await expect(async () => + unsafeEvaluateAsync( + ` + const fn = () => { + setTimeout(() => console.log('ping timeout'), 100) + }; + + fn() + `, + {}, + 50 + ) + ).rejects.toEqual(new Error('Timeout exceeded')); + }); + + it('applies timeout when using setInterval', async () => { + await expect(async () => + unsafeEvaluateAsync( + ` + const fn = () => { + const someFn = () => {} + setInterval(someFn, 10) + }; + + fn() + `, + {}, + 50 + ) + ).rejects.toEqual(new Error('Timeout exceeded')); + }); + + it('processing can call reject', async () => { + await expect(async () => + unsafeEvaluateAsync(`reject(new Error('Rejected by processing snippet.'))`, {}, 50) + ).rejects.toEqual(new Error('Rejected by processing snippet.')); + }); + + it('throws on exception', async () => { + await expect(async () => unsafeEvaluateAsync("throw new Error('unexpected')", {}, 5000)).rejects.toEqual( + new Error('unexpected') + ); + }); +}); diff --git a/src/processing/unsafe-evaluate.ts b/src/processing/unsafe-evaluate.ts new file mode 100644 index 0000000..b795509 --- /dev/null +++ b/src/processing/unsafe-evaluate.ts @@ -0,0 +1,178 @@ +/* eslint-disable camelcase */ +import assert from 'node:assert'; +import async_hooks from 'node:async_hooks'; +import buffer from 'node:buffer'; +import child_process from 'node:child_process'; +import cluster from 'node:cluster'; +import console from 'node:console'; +import constants from 'node:constants'; +import crypto from 'node:crypto'; +import dgram from 'node:dgram'; +import dns from 'node:dns'; +import events from 'node:events'; +import fs from 'node:fs'; +import http from 'node:http'; +import http2 from 'node:http2'; +import https from 'node:https'; +import inspector from 'node:inspector'; +import module from 'node:module'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import perf_hooks from 'node:perf_hooks'; +import process from 'node:process'; +import readline from 'node:readline'; +import repl from 'node:repl'; +import stream from 'node:stream'; +import string_decoder from 'node:string_decoder'; +import timers from 'node:timers'; +import tls from 'node:tls'; +import trace_events from 'node:trace_events'; +import tty from 'node:tty'; +import url from 'node:url'; +import util from 'node:util'; +import v8 from 'node:v8'; +import vm from 'node:vm'; +import worker_threads from 'node:worker_threads'; +import zlib from 'node:zlib'; + +import { createTimers } from './vm-timers'; + +const builtInNodeModules = { + assert, + async_hooks, + buffer, + child_process, + cluster, + console, + constants, + crypto, + dgram, + dns, + events, + fs, + http, + http2, + https, + inspector, + module, + net, + os, + path, + perf_hooks, + process, + readline, + repl, + stream, + string_decoder, + timers, + tls, + trace_events, + tty, + url, + util, + v8, + vm, + worker_threads, + zlib, +}; + +/** + * Evaluates the provided code in a new VM context with the specified global variables. + * + * **Security Warning:** This function executes the provided code and can have unintended side effects or + * vulnerabilities if used with untrusted or malicious input. It's imperative to use this function only with code you + * trust completely. Avoid using this function with user-generated code or third-party code that hasn't been thoroughly + * reviewed. + * + * @param code The JavaScript code to evaluate. + * @param globalVariables A key-value pair of variables to be made available in the context of the executed code. + * @param timeout Duration in milliseconds to wait before terminating the execution. + * + * @returns The result of the evaluated code. + * + * @throws Throws an error if the execution exceeds the provided timeout or if there's a problem with the code. + * + * @example + * + *const result = unsafeEvaluate('const output = input + 1;', { input: 1 }, 1000); + *console.log(result); // Outputs: 2 + */ +export const unsafeEvaluate = (code: string, globalVariables: Record, timeout: number) => { + const vmContext = { + ...globalVariables, + ...builtInNodeModules, + deferredOutput: undefined as unknown, + }; + + vm.runInNewContext(`${code}; deferredOutput = output;`, vmContext, { + displayErrors: true, + timeout, + }); + + return vmContext.deferredOutput; +}; + +/** + * Asynchronously evaluates the provided code in a new VM context with the specified global variables. + * + * **Security Warning:** This function executes the provided code and can have unintended side effects or + * vulnerabilities if used with untrusted or malicious input. It's imperative to use this function only with code you + * trust completely. Avoid using this function with user-generated code or third-party code that hasn't been thoroughly + * reviewed. + * + * @param code The JavaScript code to evaluate. The code should call the `resolve` method to return the result of the + * evaluation. You may use async/await syntax in the code. + * @param globalVariables A key-value pair of variables to be made available in the context of the executed code. + * @param timeout Duration in milliseconds to wait before terminating the execution. + * + * @returns The result of the evaluated code wrapped in a Promise. + * + * @throws Throws an error if the execution exceeds the provided timeout or if there's a problem with the code. + * + * @example + * + *const result = await unsafeEvaluateAsync( + * "const output = {...input, c: 'some-value'}; resolve(output);", + * { input: { a: true, b: 123 } }, + * 5000 + *); + *console.log(result); // Outputs: { a: true, b: 123, c: 'some-value' } + */ +export const unsafeEvaluateAsync = async (code: string, globalVariables: Record, timeout: number) => { + let vmReject: (reason: unknown) => void; + + // Make sure the timeout is applied. When the processing snippet uses setTimeout or setInterval, the timeout option + // from VM is broken. See: https://github.com/nodejs/node/issues/3020. + // + // We need to manually clear all timers and reject the processing manually. + const timeoutTimer = setTimeout(() => { + vmReject(new Error('Timeout exceeded')); + }, timeout); + + return new Promise((resolve, reject) => { + const timers = createTimers(); + const vmResolve = (value: unknown) => { + timers.clearAll(); + clearTimeout(timeoutTimer); + resolve(value); + }; + vmReject = (reason: unknown) => { + timers.clearAll(); + clearTimeout(timeoutTimer); + reject(reason); + }; + + const vmContext = { + ...globalVariables, + ...builtInNodeModules, + resolve: vmResolve, + reject: vmReject, + setTimeout: timers.customSetTimeout, + setInterval: timers.customSetInterval, + clearTimeout: timers.customClearTimeout, + clearInterval: timers.customClearInterval, + }; + vm.runInNewContext(code, vmContext, { displayErrors: true, timeout }); + }); +}; diff --git a/src/processing/vm-timers.ts b/src/processing/vm-timers.ts new file mode 100644 index 0000000..98c48cf --- /dev/null +++ b/src/processing/vm-timers.ts @@ -0,0 +1,58 @@ +/** + * Timers (setTimeout, setInterval) do not work in Node.js vm, see: https://github.com/nodejs/help/issues/1875 + * + * The API is wrapped in a "create" function so that every processing snippet keeps track of its timers and properly + * cleans them up after use. + */ +export const createTimers = () => { + let timeouts: NodeJS.Timeout[] = []; + + const customSetTimeout = (fn: () => void, ms: number) => { + timeouts.push(setTimeout(fn, ms)); + }; + + const customClearTimeout = (id: NodeJS.Timeout) => { + timeouts = timeouts.filter((timeoutId) => timeoutId !== id); + clearTimeout(id); + }; + + const clearAllTimeouts = () => { + for (const element of timeouts) { + clearTimeout(element); + } + timeouts = []; + }; + + let intervals: NodeJS.Timeout[] = []; + + const customSetInterval = (fn: () => void, ms: number) => { + intervals.push(setInterval(fn, ms)); + }; + + const customClearInterval = (id: NodeJS.Timeout) => { + intervals = intervals.filter((intervalId) => intervalId !== id); + clearInterval(id); + }; + + const clearAllIntervals = () => { + for (const element of intervals) { + clearInterval(element); + } + intervals = []; + }; + + const clearAll = () => { + clearAllTimeouts(); + clearAllIntervals(); + }; + + return { + customSetTimeout, + customClearTimeout, + clearAllTimeouts, + customSetInterval, + customClearInterval, + clearAllIntervals, + clearAll, + }; +}; diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..9e644dc --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,49 @@ +import type { Endpoint } from '@api3/ois'; + +export const createEndpoint = (overrides: Partial): Endpoint => { + return { + name: 'convertToUSD', + operation: { + method: 'get', + path: '/convert', + }, + fixedOperationParameters: [ + { + operationParameter: { + in: 'query', + name: 'to', + }, + value: 'USD', + }, + ], + reservedParameters: [ + { name: '_type' }, + { name: '_path' }, + { + name: '_times', + default: '100000', + }, + { name: '_gasPrice' }, + { name: '_minConfirmations' }, + ], + parameters: [ + { + name: 'from', + default: 'EUR', + operationParameter: { + in: 'query', + name: 'from', + }, + }, + { + name: 'amount', + default: '1', + operationParameter: { + name: 'amount', + in: 'query', + }, + }, + ], + ...overrides, + }; +};