Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/test_and_release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,24 @@ jobs:
uses: apify/workflows/pnpm-install@main
- run: pnpm lint

format-check:
name: Format check
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install pnpm and dependencies
uses: apify/workflows/pnpm-install@main
- run: pnpm format:check

publish:
name: Publish to NPM
if: github.ref == 'refs/heads/master'
needs: [ test, build, lint ]
needs: [ test, build, lint, format-check ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand Down
16 changes: 16 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"ignorePatterns": [
"**/*.md",
"**/*.json",
"**/*.jsonc",
"**/*.yaml",
"**/*.yml",
"**/node_modules",
"**/dist",
"coverage"
]
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"release": "pnpm build && lerna version patch && lerna publish from-package --contents dist",
"lint": "oxlint --type-aware",
"lint:fix": "oxlint --type-aware --fix",
"format": "oxfmt",
"format:check": "oxfmt --check",
"preinstall": "npx only-allow pnpm"
},
"commitlint": {
Expand All @@ -42,6 +44,7 @@
},
"lint-staged": {
"*.ts": [
"oxfmt --write --no-error-on-unmatched-pattern",
"oxlint --type-aware --fix --no-error-on-unmatched-pattern"
]
},
Expand All @@ -60,6 +63,7 @@
"lerna": "^9.0.6",
"lint-staged": "^16.0.0",
"nock": "^14.0.0",
"oxfmt": "0.46.0",
"oxlint": "1.62.0",
"oxlint-tsgolint": "0.22.0",
"rimraf": "^6.0.1",
Expand Down
101 changes: 57 additions & 44 deletions packages/actor-memory-expression/src/memory_calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,23 @@ const math = create({
const { compile } = math;

// Disable potentially dangerous functions
math.import({
// We disable evaluate to prevent users from calling it inside their expressions.
// For example: defaultMemoryMbytes = "evaluate('2 + 2')"
evaluate() { throw new Error('Function evaluate is disabled.'); },
compile() { throw new Error('Function compile is disabled.'); },
// We need to disable it, because compileDependencies imports parseDependencies.
parse() { throw new Error('Function parse is disabled.'); },
}, { override: true });
math.import(
{
// We disable evaluate to prevent users from calling it inside their expressions.
// For example: defaultMemoryMbytes = "evaluate('2 + 2')"
evaluate() {
throw new Error('Function evaluate is disabled.');
},
compile() {
throw new Error('Function compile is disabled.');
},
// We need to disable it, because compileDependencies imports parseDependencies.
parse() {
throw new Error('Function parse is disabled.');
},
},
{ override: true },
);

/**
* Safely retrieves a nested property from an object using a dot-notation string path.
Expand All @@ -89,17 +98,17 @@ math.import({
* @param path A dot-separated string representing the nested path (e.g., "input.payload.size").
* @param defaultVal The value to return if the path is not found or the value is `null` or `undefined`.
* @returns The retrieved value, or `defaultVal` if the path is unreachable.
*/
*/
const customGetFunc = (obj: any, path: string, defaultVal?: number) => {
return (path.split('.').reduce((current, key) => current?.[key], obj)) ?? defaultVal;
return path.split('.').reduce((current, key) => current?.[key], obj) ?? defaultVal;
};

/**
* Rounds a number to the closest power of 2.
* The result is clamped to the allowed range (ACTOR_LIMITS.MIN_RUN_MEMORY_MBYTES - ACTOR_LIMITS.MAX_RUN_MEMORY_MBYTES).
* @param num The number to round.
* @returns The closest power of 2 within min/max range.
*/
*/
const roundToClosestPowerOf2 = (num: number): number => {
if (typeof num !== 'number' || Number.isNaN(num) || !Number.isFinite(num)) {
throw new Error(`Calculated memory value is not a valid number: ${num}.`);
Expand Down Expand Up @@ -139,44 +148,46 @@ const roundToClosestPowerOf2 = (num: number): number => {
const processTemplateVariables = (defaultMemoryMbytes: string): string => {
const variableRegex = /{{\s*([a-zA-Z0-9_.]+)\s*}}/g;

const processedExpression = defaultMemoryMbytes.replace(
variableRegex,
(_, variableName: string) => {
// 1. Check if the variable is accessing input (e.g. {{input.someValue}})
// We do not validate the specific property name because `input` is dynamic.
if (variableName.startsWith('input.')) {
return variableName;
const processedExpression = defaultMemoryMbytes.replace(variableRegex, (_, variableName: string) => {
// 1. Check if the variable is accessing input (e.g. {{input.someValue}})
// We do not validate the specific property name because `input` is dynamic.
if (variableName.startsWith('input.')) {
return variableName;
}

// 2. Check if the variable is accessing runOptions (e.g. {{runOptions.memoryMbytes}}) and validate the keys.
if (variableName.startsWith('runOptions.')) {
const key = variableName.slice('runOptions.'.length);
if (!ALLOWED_RUN_OPTION_KEYS.has(key as keyof ActorRunOptions)) {
throw new Error(
`Invalid variable '{{${variableName}}}' in expression. Only the following runOptions are allowed: ${Array.from(
ALLOWED_RUN_OPTION_KEYS,
)
.map((k) => `runOptions.${k}`)
.join(', ')}.`,
);
}
return variableName;
}

// 2. Check if the variable is accessing runOptions (e.g. {{runOptions.memoryMbytes}}) and validate the keys.
if (variableName.startsWith('runOptions.')) {
const key = variableName.slice('runOptions.'.length);
if (!ALLOWED_RUN_OPTION_KEYS.has(key as keyof ActorRunOptions)) {
throw new Error(
`Invalid variable '{{${variableName}}}' in expression. Only the following runOptions are allowed: ${Array.from(ALLOWED_RUN_OPTION_KEYS).map((k) => `runOptions.${k}`).join(', ')}.`,
);
}
return variableName;
}

// 3. Throw error for unrecognized variables (e.g. {{someVariable}})
throw new Error(
`Invalid variable '{{${variableName}}}' in expression.`,
);
},
);
// 3. Throw error for unrecognized variables (e.g. {{someVariable}})
throw new Error(`Invalid variable '{{${variableName}}}' in expression.`);
});

return processedExpression;
};

/*
* Retrieves a compiled expression from the cache or compiles it if not present.
*
* @param expression The expression string to compile.
* @param cache An optional cache to store/retrieve compiled expressions.
* @returns The compiled CompilationResult.
*/
const getCompiledExpression = async (expression: string, cache: CompilationCache | undefined): Promise<CompilationResult> => {
* Retrieves a compiled expression from the cache or compiles it if not present.
*
* @param expression The expression string to compile.
* @param cache An optional cache to store/retrieve compiled expressions.
* @returns The compiled CompilationResult.
*/
const getCompiledExpression = async (
expression: string,
cache: CompilationCache | undefined,
): Promise<CompilationResult> => {
if (!cache) {
return compile(expression);
}
Expand All @@ -199,14 +210,16 @@ const getCompiledExpression = async (expression: string, cache: CompilationCache
* @param context The `MemoryEvaluationContext` (containing `input` and `runOptions`) available to the expression.
* @param options.cache Optional synchronous cache. Since compiled functions cannot be saved to a database/Redis, they are kept in local memory.
* @returns The calculated memory value rounded to the closest power of 2 and clamped within allowed limits.
*/
*/
export const calculateRunDynamicMemory = async (
defaultMemoryMbytes: string,
context: MemoryEvaluationContext,
options: { cache: CompilationCache } | undefined = undefined,
) => {
if (defaultMemoryMbytes.length > DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH) {
throw new Error(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`);
throw new Error(
`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`,
);
}

// Replaces all occurrences of {{variable}} with variable
Expand Down
6 changes: 3 additions & 3 deletions packages/actor-memory-expression/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ export type ActorRunOptions = {
maxItems?: number;
maxTotalChargeUsd?: number;
restartOnError?: boolean;
}
};

export type MemoryEvaluationContext = {
runOptions: ActorRunOptions;
input: Record<string, unknown>;
}
};

export type CompilationCache = {
get: (expression: string) => Promise<EvalFunction | null>;
set: (expression: string, compilationResult: EvalFunction) => Promise<void>;
size: () => Promise<number>;
}
};

export type CompilationResult = EvalFunction;
22 changes: 6 additions & 16 deletions packages/consts/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,9 @@ export const INTEGER_ENV_VARS = [
APIFY_ENV_VARS.SYSTEM_INFO_INTERVAL_MILLIS,
] as const;

export const COMMA_SEPARATED_LIST_ENV_VARS = [
ACTOR_ENV_VARS.BUILD_TAGS,
] as const;
export const COMMA_SEPARATED_LIST_ENV_VARS = [ACTOR_ENV_VARS.BUILD_TAGS] as const;

export const JSON_ENCODED_ENV_VARS = [
ACTOR_ENV_VARS.STORAGES_JSON,
] as const;
export const JSON_ENCODED_ENV_VARS = [ACTOR_ENV_VARS.STORAGES_JSON] as const;

/**
* Dictionary of names of build-time variables passed to the Actor's Docker build process.
Expand Down Expand Up @@ -577,16 +573,10 @@ export const WEBHOOK_DEFAULT_PAYLOAD_TEMPLATE = `{
"eventData": {{eventData}},
"resource": {{resource}}
}`;
export const WEBHOOK_ALLOWED_PAYLOAD_VARIABLES = new Set([
'userId',
'createdAt',
'eventType',
'eventData',
'resource',
]);
export const WEBHOOK_ALLOWED_PAYLOAD_VARIABLES = new Set(['userId', 'createdAt', 'eventType', 'eventData', 'resource']);

// Max allowed size of files in multi-file editor
export const MAX_MULTIFILE_BYTES = 3 * (1024 ** 2); // 3MB
export const MAX_MULTIFILE_BYTES = 3 * 1024 ** 2; // 3MB

// Formats for multi-file editor files
export const SOURCE_FILE_FORMATS = {
Expand Down Expand Up @@ -666,7 +656,7 @@ export const STORAGE_GENERAL_ACCESS = {
ANYONE_WITH_NAME_CAN_READ: 'ANYONE_WITH_NAME_CAN_READ',
} as const;

export type STORAGE_GENERAL_ACCESS = ValueOf<typeof STORAGE_GENERAL_ACCESS>
export type STORAGE_GENERAL_ACCESS = ValueOf<typeof STORAGE_GENERAL_ACCESS>;

/**
* Run setting determining how others can access the run.
Expand All @@ -684,7 +674,7 @@ export const RUN_GENERAL_ACCESS = {
ANYONE_WITH_ID_CAN_READ: 'ANYONE_WITH_ID_CAN_READ',
} as const;

export type RUN_GENERAL_ACCESS = ValueOf<typeof RUN_GENERAL_ACCESS>
export type RUN_GENERAL_ACCESS = ValueOf<typeof RUN_GENERAL_ACCESS>;

/**
* Determines permissions that the Actor requires to run.
Expand Down
Loading
Loading