refactor(server): split LambdaDriver and LocalDriver into focused sub-services (#21153)

## Why

`LambdaDriver` (1.4k lines) and `LocalDriver` (709 lines) each mixed
many unrelated concerns in a single class.

## What changed

**This PR only moves code** — every line is byte-for-byte the same as
`main` (same logic, same comments, same constants, same lock keys/TTLs,
same error mapping). The drivers are now thin orchestrators wiring
co-located sub-services + pure utils (each with unit tests).

| File | Before | After |
|---|---|---|
| `lambda.driver.ts` | 1425 | 252 |
| `local.driver.ts`  | 709  | 231 |
| Largest sub-service | — | 371 (`lambda-executor-manager`) |
| Unit tests on these paths | 0 | 15 |

Lambda split: `lambda-aws-client` / `lambda-tool-functions` /
`lambda-layer-manager` / `lambda-executor-manager` + `constants` +
`types` + 5 pure utils.

Local split: `local-layer-manager` / `local-child-process-runner` /
`local-prebuilt-bundle` + `constants` + `types` + 3 pure utils.

No behavior change, no signature change, no public-API change.
This commit is contained in:
Charles Bochet
2026-06-02 15:43:11 +00:00
committed by GitHub
parent 1833fa84a5
commit f6a17bc5f7
28 changed files with 2177 additions and 1721 deletions
@@ -0,0 +1,43 @@
import { join, resolve } from 'path';
import { ASSET_PATH } from 'src/constants/assets-path';
export const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
export const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
export const YARN_INSTALL_LAMBDA_TIMEOUT_SECONDS = 300;
export const YARN_INSTALL_LAMBDA_MEMORY_MB = 1024;
export const BUILDER_LAMBDA_TIMEOUT_SECONDS = 60;
export const BUILDER_LAMBDA_MEMORY_MB = 512;
export const EXECUTOR_LAMBDA_MEMORY_MB = 512;
export const EXECUTOR_LAMBDA_TIMEOUT_SECONDS = 900;
export const LAMBDA_EPHEMERAL_STORAGE_MB = 4096;
export const COMMON_LAYER_NAME_PREFIX = 'twenty-common-layer';
export const YARN_INSTALL_FUNCTION_NAME_PREFIX = 'twenty-yarn-install';
export const BUILDER_FUNCTION_NAME_PREFIX = 'twenty-builder';
export const SDK_LAYER_PREFIX_IN_ZIP = 'nodejs/node_modules/twenty-client-sdk';
export const LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG = 'twenty:bundle-checksum';
export const PREBUILT_BUNDLE_FILE_NAME = 'prebuilt-logic-function.mjs';
export const PREBUILT_INSTALL_LOCK_TTL_MS = 180_000;
export const PREBUILT_INSTALL_LOCK_RETRY_MS = 1_000;
export const PREBUILT_INSTALL_LOCK_MAX_RETRIES = 180;
export const YARN_INSTALL_HANDLER_PATH = resolve(
__dirname,
join(
ASSET_PATH,
'engine/core-modules/logic-function/logic-function-drivers/constants/yarn-install/index.mjs',
),
);
export const BUILDER_HANDLER_PATH = resolve(
__dirname,
join(
ASSET_PATH,
'engine/core-modules/logic-function/logic-function-drivers/constants/builder/index.mjs',
),
);
@@ -0,0 +1,145 @@
import {
Lambda,
ListLayerVersionsCommand,
waitUntilFunctionActiveV2,
waitUntilFunctionUpdatedV2,
} from '@aws-sdk/client-lambda';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { isDefined } from 'twenty-shared/utils';
import {
CREDENTIALS_DURATION_IN_SECONDS,
UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant';
import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type';
export class LambdaAwsClientService {
private lambdaClient: Lambda | undefined;
private assumeRoleCredentials:
| { accessKeyId: string; secretAccessKey: string; sessionToken: string }
| undefined;
private credentialsExpiry: Date | null = null;
constructor(private readonly options: LambdaDriverOptions) {}
async getLambdaClient() {
if (
!isDefined(this.lambdaClient) ||
(isDefined(this.options.subhostingRole) &&
this.areAssumeRoleCredentialsExpired())
) {
this.lambdaClient = new Lambda({
...this.options,
...(isDefined(this.options.subhostingRole) && {
credentials: await this.getAssumeRoleCredentials(),
}),
});
}
return this.lambdaClient;
}
async generatePresignedUploadUrl(
s3Key: string,
expiresIn: number = 300,
): Promise<string> {
const s3Client = new S3Client({
region: this.options.layerBucketRegion,
credentials: isDefined(this.options.subhostingRole)
? await this.getAssumeRoleCredentials()
: this.options.credentials,
});
const putCommand = new PutObjectCommand({
Bucket: this.options.layerBucket,
Key: s3Key,
ContentType: 'application/zip',
});
return getSignedUrl(s3Client, putCommand, { expiresIn });
}
async waitFunctionActive(
functionName: string,
maxWaitTime: number = UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS,
): Promise<void> {
await waitUntilFunctionActiveV2(
{ client: await this.getLambdaClient(), maxWaitTime },
{ FunctionName: functionName },
);
}
async waitFunctionUpdated(
functionName: string,
maxWaitTime: number = UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS,
): Promise<void> {
await waitUntilFunctionUpdatedV2(
{ client: await this.getLambdaClient(), maxWaitTime },
{ FunctionName: functionName },
);
}
async getExistingLayerArn(layerName: string): Promise<string | undefined> {
const lambdaClient = await this.getLambdaClient();
const listLayerResult = await lambdaClient.send(
new ListLayerVersionsCommand({
LayerName: layerName,
MaxItems: 1,
}),
);
return listLayerResult.LayerVersions?.[0]?.LayerVersionArn;
}
private areAssumeRoleCredentialsExpired(): boolean {
return (
!isDefined(this.assumeRoleCredentials) ||
(isDefined(this.credentialsExpiry) &&
new Date() >= this.credentialsExpiry)
);
}
private async refreshAssumeRoleCredentials() {
const stsClient = new STSClient({ region: this.options.region });
const assumeRoleCommand = new AssumeRoleCommand({
RoleArn: this.options.subhostingRole,
RoleSessionName: 'LambdaSession',
DurationSeconds: CREDENTIALS_DURATION_IN_SECONDS,
});
const { Credentials } = await stsClient.send(assumeRoleCommand);
if (
!isDefined(Credentials) ||
!isDefined(Credentials.AccessKeyId) ||
!isDefined(Credentials.SecretAccessKey) ||
!isDefined(Credentials.SessionToken)
) {
throw new Error('Failed to assume role');
}
this.assumeRoleCredentials = {
accessKeyId: Credentials.AccessKeyId,
secretAccessKey: Credentials.SecretAccessKey,
sessionToken: Credentials.SessionToken,
};
this.credentialsExpiry = new Date(
Date.now() + (CREDENTIALS_DURATION_IN_SECONDS - 60 * 5) * 1000,
);
this.lambdaClient = undefined;
}
private async getAssumeRoleCredentials() {
if (this.areAssumeRoleCredentialsExpired()) {
await this.refreshAssumeRoleCredentials();
}
return this.assumeRoleCredentials!;
}
}
@@ -0,0 +1,369 @@
import * as fs from 'fs/promises';
import { join } from 'path';
import {
CreateFunctionCommand,
type CreateFunctionCommandInput,
DeleteFunctionCommand,
GetFunctionCommand,
type GetFunctionCommandOutput,
ResourceNotFoundException,
TagResourceCommand,
UpdateFunctionCodeCommand,
UpdateFunctionConfigurationCommand,
} from '@aws-sdk/client-lambda';
import { Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import {
EXECUTOR_LAMBDA_MEMORY_MB,
EXECUTOR_LAMBDA_TIMEOUT_SECONDS,
LAMBDA_EPHEMERAL_STORAGE_MB,
LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG,
PREBUILT_BUNDLE_FILE_NAME,
PREBUILT_INSTALL_LOCK_MAX_RETRIES,
PREBUILT_INSTALL_LOCK_RETRY_MS,
PREBUILT_INSTALL_LOCK_TTL_MS,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant';
import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type';
import { type LambdaAwsClientService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-aws-client.service';
import { type LambdaLayerManagerService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-layer-manager.service';
import { copyExecutor } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-executor';
import { createZipFile } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/create-zip-file';
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
type ExecutorBuildContext = {
flatLogicFunction: FlatLogicFunction;
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
};
export class LambdaExecutorManagerService {
private readonly logger = new Logger(LambdaExecutorManagerService.name);
constructor(
private readonly options: Pick<LambdaDriverOptions, 'lambdaRole'>,
private readonly awsClient: LambdaAwsClientService,
private readonly layerManager: LambdaLayerManagerService,
private readonly cacheLockService: CacheLockService,
private readonly logicFunctionResourceService: LogicFunctionResourceService,
) {}
async getLambdaExecutor(
flatLogicFunction: FlatLogicFunction,
): Promise<GetFunctionCommandOutput | undefined> {
const lambdaClient = await this.awsClient.getLambdaClient();
try {
return await lambdaClient.send(
new GetFunctionCommand({ FunctionName: flatLogicFunction.id }),
);
} catch (error) {
if (error instanceof ResourceNotFoundException) {
return undefined;
}
throw error;
}
}
async delete(flatLogicFunction: FlatLogicFunction): Promise<void> {
const lambdaExecutor = await this.getLambdaExecutor(flatLogicFunction);
if (!isDefined(lambdaExecutor)) {
return;
}
const lambdaClient = await this.awsClient.getLambdaClient();
await lambdaClient.send(
new DeleteFunctionCommand({ FunctionName: flatLogicFunction.id }),
);
}
async buildExecutor(context: ExecutorBuildContext): Promise<void> {
const { canSkip } = await this.checkBuildStatus(context);
if (canSkip) {
return;
}
const buildLockTtlMs = 120_000;
const buildLockRetryMs = 500;
const buildLockMaxRetries = 240;
await this.cacheLockService.withLock(
async () => {
// Need to check again inside the lock in case lock was not acquired immediately.
const { canSkip: canSkipAfterLock, lambdaExecutor } =
await this.checkBuildStatus(context);
if (canSkipAfterLock) {
return;
}
await this.ensureExecutor({ ...context, lambdaExecutor });
},
`lambda-build:${context.flatLogicFunction.id}`,
{
ttl: buildLockTtlMs,
ms: buildLockRetryMs,
maxRetries: buildLockMaxRetries,
},
);
}
async installPrebuiltBundle(context: ExecutorBuildContext): Promise<void> {
const { flatLogicFunction, applicationUniversalIdentifier } = context;
if (!isNonEmptyString(flatLogicFunction.checksum)) {
throw new LogicFunctionException(
`Cannot install prebuilt bundle for function '${flatLogicFunction.id}' without a checksum`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
);
}
const checksum = flatLogicFunction.checksum;
await this.buildExecutor(context);
await this.cacheLockService.withLock(
async () => {
const compiledCode =
await this.logicFunctionResourceService.getBuiltCode({
workspaceId: flatLogicFunction.workspaceId,
applicationUniversalIdentifier,
builtHandlerPath: flatLogicFunction.builtHandlerPath,
});
const temporaryDirManager = new TemporaryDirManager();
const { sourceTemporaryDir, lambdaZipPath } =
await temporaryDirManager.init();
try {
await copyExecutor(sourceTemporaryDir);
await fs.writeFile(
join(sourceTemporaryDir, PREBUILT_BUNDLE_FILE_NAME),
compiledCode,
'utf8',
);
await createZipFile(sourceTemporaryDir, lambdaZipPath);
const lambdaClient = await this.awsClient.getLambdaClient();
const updateResult = await lambdaClient.send(
new UpdateFunctionCodeCommand({
FunctionName: flatLogicFunction.id,
ZipFile: await fs.readFile(lambdaZipPath),
}),
);
await this.awsClient.waitFunctionUpdated(flatLogicFunction.id);
const functionArn = updateResult.FunctionArn;
if (!isNonEmptyString(functionArn)) {
throw new LogicFunctionException(
`UpdateFunctionCode did not return a FunctionArn for '${flatLogicFunction.id}'`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
);
}
await lambdaClient.send(
new TagResourceCommand({
Resource: functionArn,
Tags: {
[LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG]: checksum,
},
}),
);
} catch (error) {
this.logger.error(
`Failed to install prebuilt bundle for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
} finally {
await temporaryDirManager.clean();
}
},
`lambda-install:${flatLogicFunction.id}`,
{
ttl: PREBUILT_INSTALL_LOCK_TTL_MS,
ms: PREBUILT_INSTALL_LOCK_RETRY_MS,
maxRetries: PREBUILT_INSTALL_LOCK_MAX_RETRIES,
},
);
}
async getInstalledBundleChecksum(
flatLogicFunction: FlatLogicFunction,
): Promise<string | null> {
const lambdaExecutor = await this.getLambdaExecutor(flatLogicFunction);
if (!isDefined(lambdaExecutor)) {
return null;
}
return lambdaExecutor.Tags?.[LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG] ?? null;
}
private async checkBuildStatus(context: ExecutorBuildContext): Promise<{
canSkip: boolean;
lambdaExecutor: GetFunctionCommandOutput | undefined;
}> {
const { flatApplication, applicationUniversalIdentifier } = context;
const lambdaExecutor = await this.getLambdaExecutor(
context.flatLogicFunction,
);
const isActive = lambdaExecutor?.Configuration?.State === 'Active';
const canSkip =
isDefined(lambdaExecutor) &&
isActive &&
!flatApplication.isSdkLayerStale &&
this.layerManager.hasExpectedLayers({
lambdaExecutor,
flatApplication,
applicationUniversalIdentifier,
});
return { canSkip, lambdaExecutor };
}
private async ensureExecutor({
flatLogicFunction,
flatApplication,
applicationUniversalIdentifier,
lambdaExecutor,
}: ExecutorBuildContext & {
lambdaExecutor: GetFunctionCommandOutput | undefined;
}): Promise<void> {
let depsLayerArn: string;
try {
depsLayerArn = await this.layerManager.ensureDepsLayer({
flatApplication,
applicationUniversalIdentifier,
});
} catch (error) {
this.logger.error(
`Failed to get dependency layer for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw new LogicFunctionException(
`Failed to get dependency layer for function '${flatLogicFunction.id}': ${error instanceof Error ? error.message : 'Unknown error'}`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED,
);
}
let sdkLayerArn: string;
try {
sdkLayerArn = await this.layerManager.ensureSdkLayer({
flatApplication,
applicationUniversalIdentifier,
});
} catch (error) {
this.logger.error(
`Failed to get SDK layer for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw new LogicFunctionException(
`Failed to get SDK layer for function '${flatLogicFunction.id}': ${error instanceof Error ? error.message : 'Unknown error'}`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED,
);
}
if (!isDefined(lambdaExecutor)) {
await this.createExecutor({
flatLogicFunction,
depsLayerArn,
sdkLayerArn,
});
await this.awsClient.waitFunctionActive(flatLogicFunction.id);
return;
}
await this.updateExecutorConfiguration({
flatLogicFunction,
depsLayerArn,
sdkLayerArn,
});
await this.awsClient.waitFunctionUpdated(flatLogicFunction.id);
}
private async updateExecutorConfiguration({
flatLogicFunction,
depsLayerArn,
sdkLayerArn,
}: {
flatLogicFunction: FlatLogicFunction;
depsLayerArn: string;
sdkLayerArn: string;
}): Promise<void> {
const lambdaClient = await this.awsClient.getLambdaClient();
await lambdaClient.send(
new UpdateFunctionConfigurationCommand({
FunctionName: flatLogicFunction.id,
Layers: [depsLayerArn, sdkLayerArn],
Runtime: flatLogicFunction.runtime,
Timeout: EXECUTOR_LAMBDA_TIMEOUT_SECONDS,
MemorySize: EXECUTOR_LAMBDA_MEMORY_MB,
}),
);
}
private async createExecutor({
flatLogicFunction,
depsLayerArn,
sdkLayerArn,
}: {
flatLogicFunction: FlatLogicFunction;
depsLayerArn: string;
sdkLayerArn: string;
}): Promise<void> {
const temporaryDirManager = new TemporaryDirManager();
const { sourceTemporaryDir, lambdaZipPath } =
await temporaryDirManager.init();
try {
await copyExecutor(sourceTemporaryDir);
await createZipFile(sourceTemporaryDir, lambdaZipPath);
// SDK layer listed last so it overwrites the stub twenty-client-sdk
// from the deps layer (later layers take precedence in /opt merge).
const params: CreateFunctionCommandInput = {
Code: {
ZipFile: await fs.readFile(lambdaZipPath),
},
FunctionName: flatLogicFunction.id,
Layers: [depsLayerArn, sdkLayerArn],
Handler: 'index.handler',
Role: this.options.lambdaRole,
Runtime: flatLogicFunction.runtime,
Timeout: EXECUTOR_LAMBDA_TIMEOUT_SECONDS,
MemorySize: EXECUTOR_LAMBDA_MEMORY_MB,
EphemeralStorage: { Size: LAMBDA_EPHEMERAL_STORAGE_MB },
};
const lambdaClient = await this.awsClient.getLambdaClient();
await lambdaClient.send(new CreateFunctionCommand(params));
} finally {
await temporaryDirManager.clean();
}
}
}
@@ -0,0 +1,253 @@
import * as fs from 'fs/promises';
import {
DeleteLayerVersionCommand,
type GetFunctionCommandOutput,
ListLayerVersionsCommand,
PublishLayerVersionCommand,
} from '@aws-sdk/client-lambda';
import { isDefined } from 'twenty-shared/utils';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { SDK_LAYER_PREFIX_IN_ZIP } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant';
import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type';
import { type LambdaAwsClientService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-aws-client.service';
import { type LambdaToolFunctionsService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service';
import { getLambdaDepsLayerName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/get-lambda-deps-layer-name.util';
import { getLambdaSdkLayerName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/get-lambda-sdk-layer-name.util';
import { reprefixLambdaZipEntries } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/reprefix-lambda-zip-entries.util';
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import { type SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service';
import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
type LayerAppContext = {
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
};
export class LambdaLayerManagerService {
constructor(
private readonly options: Pick<LambdaDriverOptions, 'layerBucket'>,
private readonly awsClient: LambdaAwsClientService,
private readonly toolFunctions: LambdaToolFunctionsService,
private readonly logicFunctionResourceService: LogicFunctionResourceService,
private readonly sdkClientArchiveService: SdkClientArchiveService,
) {}
async ensureDepsLayer(context: LayerAppContext): Promise<string> {
const layerName = getLambdaDepsLayerName(context.flatApplication);
const existingArn = await this.awsClient.getExistingLayerArn(layerName);
if (isDefined(existingArn)) {
return existingArn;
}
await this.createDepsLayer({ ...context, layerName });
const newArn = await this.awsClient.getExistingLayerArn(layerName);
if (!isDefined(newArn)) {
throw new Error(
`Layer '${layerName}' was not created by the yarn install Lambda`,
);
}
return newArn;
}
async ensureSdkLayer(context: LayerAppContext): Promise<string> {
const { flatApplication, applicationUniversalIdentifier } = context;
const layerName = getLambdaSdkLayerName({
workspaceId: flatApplication.workspaceId,
applicationUniversalIdentifier,
});
if (!flatApplication.isSdkLayerStale) {
const existingArn = await this.awsClient.getExistingLayerArn(layerName);
if (isDefined(existingArn)) {
return existingArn;
}
}
await this.deleteAllLayerVersions(layerName);
const sdkArchiveBuffer =
await this.sdkClientArchiveService.downloadArchiveBuffer({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
});
const zipBuffer = await reprefixLambdaZipEntries({
sourceBuffer: sdkArchiveBuffer,
prefix: SDK_LAYER_PREFIX_IN_ZIP,
});
const arn = await this.publishLayer({ layerName, zipBuffer });
await this.sdkClientArchiveService.markSdkLayerFresh({
applicationId: flatApplication.id,
workspaceId: flatApplication.workspaceId,
});
return arn;
}
hasExpectedLayers({
lambdaExecutor,
flatApplication,
applicationUniversalIdentifier,
}: LayerAppContext & {
lambdaExecutor: GetFunctionCommandOutput;
}): boolean {
const layers = lambdaExecutor.Configuration?.Layers;
if (!isDefined(layers) || layers.length !== 2) {
return false;
}
const depsLayerName = getLambdaDepsLayerName(flatApplication);
const sdkLayerName = getLambdaSdkLayerName({
workspaceId: flatApplication.workspaceId,
applicationUniversalIdentifier,
});
return (
layers.some((layer) => layer.Arn?.includes(depsLayerName)) &&
layers.some((layer) => layer.Arn?.includes(sdkLayerName))
);
}
private async createDepsLayer({
flatApplication,
applicationUniversalIdentifier,
layerName,
}: LayerAppContext & { layerName: string }): Promise<void> {
const existingArn = await this.awsClient.getExistingLayerArn(layerName);
if (isDefined(existingArn)) {
return;
}
const { packageJson, yarnLock } = await this.getDependencyContents({
flatApplication,
applicationUniversalIdentifier,
});
const s3Key = `lambda-layers/${layerName}.zip`;
const presignedUploadUrl =
await this.awsClient.generatePresignedUploadUrl(s3Key);
await this.toolFunctions.runYarnInstallCreateLayer({
packageJson,
yarnLock,
presignedUploadUrl,
});
const lambdaClient = await this.awsClient.getLambdaClient();
const publishResult = await lambdaClient.send(
new PublishLayerVersionCommand({
LayerName: layerName,
Content: {
S3Bucket: this.options.layerBucket,
S3Key: s3Key,
},
CompatibleRuntimes: [
LogicFunctionRuntime.NODE18,
LogicFunctionRuntime.NODE22,
],
}),
);
if (!publishResult.LayerVersionArn) {
throw new Error(
`PublishLayerVersion did not return a LayerVersionArn for layer '${layerName}'`,
);
}
}
private async getDependencyContents({
flatApplication,
applicationUniversalIdentifier,
}: LayerAppContext): Promise<{ packageJson: string; yarnLock: string }> {
const temporaryDirManager = new TemporaryDirManager();
const { sourceTemporaryDir } = await temporaryDirManager.init();
try {
await this.logicFunctionResourceService.copyDependenciesInMemory({
applicationUniversalIdentifier,
workspaceId: flatApplication.workspaceId,
inMemoryFolderPath: sourceTemporaryDir,
});
const [packageJson, yarnLock] = await Promise.all([
fs.readFile(`${sourceTemporaryDir}/package.json`, 'utf-8'),
fs.readFile(`${sourceTemporaryDir}/yarn.lock`, 'utf-8'),
]);
return { packageJson, yarnLock };
} finally {
await temporaryDirManager.clean();
}
}
private async publishLayer({
layerName,
zipBuffer,
}: {
layerName: string;
zipBuffer: Buffer;
}): Promise<string> {
const lambdaClient = await this.awsClient.getLambdaClient();
const result = await lambdaClient.send(
new PublishLayerVersionCommand({
LayerName: layerName,
Content: { ZipFile: zipBuffer },
CompatibleRuntimes: [
LogicFunctionRuntime.NODE18,
LogicFunctionRuntime.NODE22,
],
}),
);
if (!isDefined(result.LayerVersionArn)) {
throw new Error('New layer version ARN is undefined');
}
return result.LayerVersionArn;
}
private async deleteAllLayerVersions(layerName: string): Promise<void> {
const lambdaClient = await this.awsClient.getLambdaClient();
let marker: string | undefined;
do {
const listResult = await lambdaClient.send(
new ListLayerVersionsCommand({
LayerName: layerName,
MaxItems: 50,
Marker: marker,
}),
);
const versions = listResult.LayerVersions ?? [];
await Promise.all(
versions.map((version) =>
lambdaClient.send(
new DeleteLayerVersionCommand({
LayerName: layerName,
VersionNumber: version.Version,
}),
),
),
);
marker = listResult.NextMarker;
} while (isDefined(marker));
}
}
@@ -0,0 +1,364 @@
import * as fs from 'fs/promises';
import { join } from 'path';
import {
CreateFunctionCommand,
type CreateFunctionCommandInput,
GetFunctionCommand,
InvokeCommand,
LogType,
PublishLayerVersionCommand,
ResourceNotFoundException,
} from '@aws-sdk/client-lambda';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { COMMON_LAYER_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/common-layer-dependencies-dirname';
import {
BUILDER_FUNCTION_NAME_PREFIX,
BUILDER_HANDLER_PATH,
BUILDER_LAMBDA_MEMORY_MB,
BUILDER_LAMBDA_TIMEOUT_SECONDS,
COMMON_LAYER_NAME_PREFIX,
LAMBDA_EPHEMERAL_STORAGE_MB,
YARN_INSTALL_FUNCTION_NAME_PREFIX,
YARN_INSTALL_HANDLER_PATH,
YARN_INSTALL_LAMBDA_MEMORY_MB,
YARN_INSTALL_LAMBDA_TIMEOUT_SECONDS,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant';
import {
type BuilderLambdaPayload,
type BuilderLambdaResult,
type LambdaDriverOptions,
type YarnInstallLambdaPayload,
type YarnInstallLambdaResult,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type';
import { computeHashedLambdaResourceName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/compute-hashed-lambda-resource-name.util';
import { type LambdaAwsClientService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-aws-client.service';
import { copyBuilder } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-builder';
import { copyCommonLayerDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-common-layer-dependencies';
import { copyYarnInstall } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-yarn-install';
import { createZipFile } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/create-zip-file';
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
export class LambdaToolFunctionsService {
private commonLayerName: string | undefined;
private yarnInstallFunctionName: string | undefined;
private builderFunctionName: string | undefined;
constructor(
private readonly options: Pick<LambdaDriverOptions, 'lambdaRole'>,
private readonly awsClient: LambdaAwsClientService,
) {}
async transpile(
params: Omit<BuilderLambdaPayload, 'action'>,
): Promise<BuilderLambdaResult> {
await this.ensureBuilderLambdaExists();
const lambdaClient = await this.awsClient.getLambdaClient();
const builderFunctionName = await this.getBuilderFunctionName();
const payload: BuilderLambdaPayload = {
action: 'transpile',
...params,
};
const result = await lambdaClient.send(
new InvokeCommand({
FunctionName: builderFunctionName,
Payload: JSON.stringify(payload),
LogType: LogType.Tail,
}),
{
abortSignal: AbortSignal.timeout(BUILDER_LAMBDA_TIMEOUT_SECONDS * 1000),
},
);
if (result.FunctionError) {
const parsedResult = result.Payload
? JSON.parse(result.Payload.transformToString())
: {};
const userCompilationErrorRegex = /^Build failed with \d+ error/;
const isUserCompilationError =
isNonEmptyString(parsedResult?.errorMessage) &&
userCompilationErrorRegex.test(parsedResult.errorMessage);
if (isUserCompilationError) {
throw new LogicFunctionException(
`Function code compilation failed: ${parsedResult.errorMessage}`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_COMPILATION_FAILED,
);
}
throw new LogicFunctionException(
`Builder Lambda failed: ${JSON.stringify(parsedResult)}`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED,
);
}
const parsedResult: BuilderLambdaResult = result.Payload
? JSON.parse(result.Payload.transformToString())
: {};
if (!parsedResult.builtCode) {
throw new Error('Builder Lambda did not return builtCode');
}
return { builtCode: parsedResult.builtCode };
}
async runYarnInstallCreateLayer(
params: Omit<YarnInstallLambdaPayload, 'action'>,
): Promise<YarnInstallLambdaResult> {
await this.ensureYarnInstallLambdaExists();
const lambdaClient = await this.awsClient.getLambdaClient();
const yarnInstallFunctionName = await this.getYarnInstallFunctionName();
const payload: YarnInstallLambdaPayload = {
action: 'createLayer',
...params,
};
const result = await lambdaClient.send(
new InvokeCommand({
FunctionName: yarnInstallFunctionName,
Payload: JSON.stringify(payload),
LogType: LogType.Tail,
}),
{
abortSignal: AbortSignal.timeout(
YARN_INSTALL_LAMBDA_TIMEOUT_SECONDS * 1000,
),
},
);
if (result.FunctionError) {
const parsedResult = result.Payload
? JSON.parse(result.Payload.transformToString())
: {};
throw new LogicFunctionException(
`Yarn install Lambda failed: ${JSON.stringify(parsedResult)}`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED,
);
}
const parsedResult: YarnInstallLambdaResult = result.Payload
? JSON.parse(result.Payload.transformToString())
: {};
if (!parsedResult.success) {
throw new Error('Yarn install Lambda did not report success');
}
return parsedResult;
}
async ensureCommonLayerExists(): Promise<string> {
const commonLayerName = await this.getCommonLayerName();
const existingArn =
await this.awsClient.getExistingLayerArn(commonLayerName);
if (isDefined(existingArn)) {
return existingArn;
}
const temporaryDirManager = new TemporaryDirManager();
const { sourceTemporaryDir, lambdaZipPath } =
await temporaryDirManager.init();
try {
await copyCommonLayerDependencies(sourceTemporaryDir);
await createZipFile(sourceTemporaryDir, lambdaZipPath);
const lambdaClient = await this.awsClient.getLambdaClient();
const result = await lambdaClient.send(
new PublishLayerVersionCommand({
LayerName: commonLayerName,
Content: { ZipFile: await fs.readFile(lambdaZipPath) },
CompatibleRuntimes: [
LogicFunctionRuntime.NODE18,
LogicFunctionRuntime.NODE22,
],
}),
);
if (!result.LayerVersionArn) {
throw new Error(
'PublishLayerVersion did not return a LayerVersionArn for common layer',
);
}
return result.LayerVersionArn;
} finally {
await temporaryDirManager.clean();
}
}
private async ensureYarnInstallLambdaExists(): Promise<void> {
const yarnInstallFunctionName = await this.getYarnInstallFunctionName();
const lambdaClient = await this.awsClient.getLambdaClient();
try {
await lambdaClient.send(
new GetFunctionCommand({ FunctionName: yarnInstallFunctionName }),
);
return;
} catch (error) {
if (!(error instanceof ResourceNotFoundException)) {
throw error;
}
}
const commonLayerArn = await this.ensureCommonLayerExists();
const temporaryDirManager = new TemporaryDirManager();
const { sourceTemporaryDir, lambdaZipPath } =
await temporaryDirManager.init();
try {
await copyYarnInstall(sourceTemporaryDir);
await createZipFile(sourceTemporaryDir, lambdaZipPath);
const params: CreateFunctionCommandInput = {
Code: {
ZipFile: await fs.readFile(lambdaZipPath),
},
FunctionName: yarnInstallFunctionName,
Layers: [commonLayerArn],
Handler: 'index.handler',
Role: this.options.lambdaRole,
Runtime: LogicFunctionRuntime.NODE22,
Timeout: YARN_INSTALL_LAMBDA_TIMEOUT_SECONDS,
MemorySize: YARN_INSTALL_LAMBDA_MEMORY_MB,
EphemeralStorage: { Size: LAMBDA_EPHEMERAL_STORAGE_MB },
};
await lambdaClient.send(new CreateFunctionCommand(params));
} finally {
await temporaryDirManager.clean();
}
await this.awsClient.waitFunctionActive(yarnInstallFunctionName);
}
private async ensureBuilderLambdaExists(): Promise<void> {
const builderFunctionName = await this.getBuilderFunctionName();
const lambdaClient = await this.awsClient.getLambdaClient();
try {
await lambdaClient.send(
new GetFunctionCommand({ FunctionName: builderFunctionName }),
);
return;
} catch (error) {
if (!(error instanceof ResourceNotFoundException)) {
throw error;
}
}
const commonLayerArn = await this.ensureCommonLayerExists();
const temporaryDirManager = new TemporaryDirManager();
const { sourceTemporaryDir, lambdaZipPath } =
await temporaryDirManager.init();
try {
await copyBuilder(sourceTemporaryDir);
await createZipFile(sourceTemporaryDir, lambdaZipPath);
const params: CreateFunctionCommandInput = {
Code: {
ZipFile: await fs.readFile(lambdaZipPath),
},
FunctionName: builderFunctionName,
Layers: [commonLayerArn],
Handler: 'index.handler',
Role: this.options.lambdaRole,
Runtime: LogicFunctionRuntime.NODE22,
Timeout: BUILDER_LAMBDA_TIMEOUT_SECONDS,
MemorySize: BUILDER_LAMBDA_MEMORY_MB,
EphemeralStorage: { Size: LAMBDA_EPHEMERAL_STORAGE_MB },
};
await lambdaClient.send(new CreateFunctionCommand(params));
} finally {
await temporaryDirManager.clean();
}
await this.awsClient.waitFunctionActive(builderFunctionName);
}
private async getCommonLayerName(): Promise<string> {
if (isDefined(this.commonLayerName)) {
return this.commonLayerName;
}
const [packageJson, yarnLock] = await Promise.all([
fs.readFile(
join(COMMON_LAYER_DEPENDENCIES_DIRNAME, 'package.json'),
'utf-8',
),
fs.readFile(
join(COMMON_LAYER_DEPENDENCIES_DIRNAME, 'yarn.lock'),
'utf-8',
),
]);
this.commonLayerName = computeHashedLambdaResourceName({
prefix: COMMON_LAYER_NAME_PREFIX,
contents: [packageJson, yarnLock],
});
return this.commonLayerName;
}
private async getYarnInstallFunctionName(): Promise<string> {
if (isDefined(this.yarnInstallFunctionName)) {
return this.yarnInstallFunctionName;
}
const handlerContent = await fs.readFile(
YARN_INSTALL_HANDLER_PATH,
'utf-8',
);
this.yarnInstallFunctionName = computeHashedLambdaResourceName({
prefix: YARN_INSTALL_FUNCTION_NAME_PREFIX,
contents: [handlerContent],
});
return this.yarnInstallFunctionName;
}
private async getBuilderFunctionName(): Promise<string> {
if (isDefined(this.builderFunctionName)) {
return this.builderFunctionName;
}
const handlerContent = await fs.readFile(BUILDER_HANDLER_PATH, 'utf-8');
this.builderFunctionName = computeHashedLambdaResourceName({
prefix: BUILDER_FUNCTION_NAME_PREFIX,
contents: [handlerContent],
});
return this.builderFunctionName;
}
}
@@ -0,0 +1,51 @@
import { type LambdaClientConfig } from '@aws-sdk/client-lambda';
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import { type SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service';
export type LambdaDriverExecutorPayload = {
code?: string;
params: object;
env: Record<string, string>;
handlerName: string;
};
export type YarnInstallLambdaPayload = {
action: 'createLayer';
packageJson: string;
yarnLock: string;
presignedUploadUrl: string;
};
export type YarnInstallLambdaResult = {
success: boolean;
};
export type BuilderLambdaPayload = {
action: 'transpile';
sourceCode: string;
sourceFileName: string;
builtFileName: string;
};
export type BuilderLambdaResult = {
builtCode: string;
};
export interface LambdaDriverOptions extends LambdaClientConfig {
logicFunctionResourceService: LogicFunctionResourceService;
sdkClientArchiveService: SdkClientArchiveService;
cacheLockService: CacheLockService;
region: string;
lambdaRole: string;
subhostingRole?: string;
layerBucket: string;
layerBucketRegion: string;
}
export enum LambdaExecutionPhase {
BUILD = 'build',
FETCH_CODE = 'fetch-code',
INVOKE = 'invoke',
}
@@ -0,0 +1,12 @@
import { computeHashedLambdaResourceName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/compute-hashed-lambda-resource-name.util';
describe('computeHashedLambdaResourceName', () => {
it('returns prefix concatenated with a 12-character sha256 hex checksum of the contents', () => {
const name = computeHashedLambdaResourceName({
prefix: 'twenty-builder',
contents: ['handler-code'],
});
expect(name).toMatch(/^twenty-builder-[0-9a-f]{12}$/);
});
});
@@ -0,0 +1,34 @@
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { getLambdaDepsLayerName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/get-lambda-deps-layer-name.util';
const buildFlatApplication = (
overrides: Partial<FlatApplication> = {},
): FlatApplication =>
({
yarnLockChecksum: 'abc123',
...overrides,
}) as FlatApplication;
describe('getLambdaDepsLayerName', () => {
it('returns deps-<checksum> when yarnLockChecksum is set', () => {
expect(getLambdaDepsLayerName(buildFlatApplication())).toBe('deps-abc123');
});
it('falls back to deps-default when yarnLockChecksum is undefined', () => {
expect(
getLambdaDepsLayerName(
buildFlatApplication({ yarnLockChecksum: undefined }),
),
).toBe('deps-default');
});
it('falls back to deps-default when yarnLockChecksum is null', () => {
expect(
getLambdaDepsLayerName(
buildFlatApplication({
yarnLockChecksum: null as unknown as string,
}),
),
).toBe('deps-default');
});
});
@@ -0,0 +1,25 @@
import { getLambdaSdkLayerName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/get-lambda-sdk-layer-name.util';
describe('getLambdaSdkLayerName', () => {
it('joins prefix with workspaceId and applicationUniversalIdentifier', () => {
expect(
getLambdaSdkLayerName({
workspaceId: 'ws-1',
applicationUniversalIdentifier: 'app-2',
}),
).toBe('sdk-ws-1-app-2');
});
it('produces distinct names for distinct identifiers', () => {
const a = getLambdaSdkLayerName({
workspaceId: 'a',
applicationUniversalIdentifier: 'x',
});
const b = getLambdaSdkLayerName({
workspaceId: 'b',
applicationUniversalIdentifier: 'x',
});
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,84 @@
import { parseLambdaLogResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/parse-lambda-log-result.util';
const toBase64 = (input: string): string =>
Buffer.from(input, 'utf8').toString('base64');
describe('parseLambdaLogResult', () => {
it('returns empty defaults when logResult is undefined', () => {
expect(parseLambdaLogResult(undefined)).toEqual({
logs: '',
initDurationMs: null,
billedDurationMs: null,
reportDurationMs: null,
coldStart: false,
});
});
it('returns empty defaults when logResult is an empty string', () => {
expect(parseLambdaLogResult('')).toEqual({
logs: '',
initDurationMs: null,
billedDurationMs: null,
reportDurationMs: null,
coldStart: false,
});
});
it('extracts Init/Billed/Duration milliseconds from a REPORT line and flags coldStart=true', () => {
const raw = [
'START RequestId: 1 Version: $LATEST',
'2026-06-02T12:00:00.000Z 11111111-1111-1111-1111-111111111111 INFO hello',
'END RequestId: 1',
'REPORT RequestId: 1 Duration: 12.34 ms Billed Duration: 13 ms Memory Size: 512 MB Max Memory Used: 100 MB Init Duration: 250.50 ms',
].join('\n');
const result = parseLambdaLogResult(toBase64(raw));
expect(result.initDurationMs).toBe('250.50');
expect(result.billedDurationMs).toBe('13');
expect(result.reportDurationMs).toBe('12.34');
expect(result.coldStart).toBe(true);
});
it('flags coldStart=false when no Init Duration is present', () => {
const raw =
'REPORT RequestId: 1 Duration: 5 ms Billed Duration: 5 ms Memory Size: 512 MB Max Memory Used: 100 MB';
const result = parseLambdaLogResult(toBase64(raw));
expect(result.initDurationMs).toBeNull();
expect(result.coldStart).toBe(false);
expect(result.reportDurationMs).toBe('5');
expect(result.billedDurationMs).toBe('5');
});
it('strips START/END/REPORT lines, request-id from INFO lines, and normalizes tabs to spaces', () => {
const raw = [
'START RequestId: abc',
'2026-06-02T12:00:00.000Z\t11111111-1111-1111-1111-111111111111\tINFO\thello',
'END RequestId: abc',
'REPORT RequestId: abc Duration: 5 ms',
].join('\n');
const result = parseLambdaLogResult(toBase64(raw));
expect(result.logs).toBe('2026-06-02T12:00:00.000Z INFO hello');
});
it('trims leading and trailing whitespace from logs', () => {
const raw = [
'START RequestId: abc',
'',
'2026-06-02T12:00:00.000Z 11111111-1111-1111-1111-111111111111 INFO line',
'',
'END RequestId: abc',
].join('\n');
const result = parseLambdaLogResult(toBase64(raw));
expect(result.logs.startsWith('2026-06-02T12:00:00.000Z INFO line')).toBe(
true,
);
expect(result.logs.endsWith('INFO line')).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { createHash } from 'crypto';
const RESOURCE_NAME_CHECKSUM_LENGTH = 12;
export const computeHashedLambdaResourceName = ({
prefix,
contents,
}: {
prefix: string;
contents: ReadonlyArray<string>;
}): string => {
const hash = createHash('sha256');
for (const content of contents) {
hash.update(content);
}
const checksum = hash.digest('hex').slice(0, RESOURCE_NAME_CHECKSUM_LENGTH);
return `${prefix}-${checksum}`;
};
@@ -0,0 +1,9 @@
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
export const getLambdaDepsLayerName = (
flatApplication: FlatApplication,
): string => {
const checksum = flatApplication.yarnLockChecksum ?? 'default';
return `deps-${checksum}`;
};
@@ -0,0 +1,7 @@
export const getLambdaSdkLayerName = ({
workspaceId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
}): string => `sdk-${workspaceId}-${applicationUniversalIdentifier}`;
@@ -0,0 +1,50 @@
export type ParsedLambdaLogResult = {
logs: string;
initDurationMs: string | null;
billedDurationMs: string | null;
reportDurationMs: string | null;
coldStart: boolean;
};
const EMPTY_PARSED_LAMBDA_LOG_RESULT: ParsedLambdaLogResult = {
logs: '',
initDurationMs: null,
billedDurationMs: null,
reportDurationMs: null,
coldStart: false,
};
export const parseLambdaLogResult = (
logResult: string | undefined,
): ParsedLambdaLogResult => {
if (!logResult) {
return EMPTY_PARSED_LAMBDA_LOG_RESULT;
}
const decoded = Buffer.from(logResult, 'base64').toString('utf8');
const initDurationMs =
decoded.match(/Init Duration:\s*([\d.]+)\s*ms/i)?.[1] ?? null;
const billedDurationMs =
decoded.match(/Billed Duration:\s*([\d.]+)\s*ms/i)?.[1] ?? null;
const reportDurationMs =
decoded.match(/\bDuration:\s*([\d.]+)\s*ms/i)?.[1] ?? null;
const logs = decoded
.split('\t')
.join(' ')
.replace(/^(START|END|REPORT).*\n?/gm, '')
.replace(
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) [a-f0-9-]+ INFO /gm,
'$1 INFO ',
)
.trim();
return {
logs,
initDurationMs,
billedDurationMs,
reportDurationMs,
coldStart: initDurationMs !== null,
};
};
@@ -0,0 +1,36 @@
// Re-wraps zip entries under a new prefix path without extracting to disk.
export const reprefixLambdaZipEntries = async ({
sourceBuffer,
prefix,
}: {
sourceBuffer: Buffer;
prefix: string;
}): Promise<Buffer> => {
const { default: unzipper } = await import('unzipper');
const archiver = (await import('archiver')).default;
const directory = await unzipper.Open.buffer(sourceBuffer);
const archive = archiver('zip', { zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
for (const entry of directory.files) {
if (entry.type === 'Directory') {
continue;
}
archive.append(entry.stream(), {
name: `${prefix}/${entry.path}`,
});
}
await new Promise<void>((resolve, reject) => {
archive.on('end', resolve);
archive.on('error', reject);
void archive.finalize();
});
return Buffer.concat(chunks);
};
@@ -1,26 +1,25 @@
import { promises as fs } from 'fs';
import { spawn } from 'node:child_process';
import { dirname, join } from 'path';
import { build } from 'esbuild';
import { NODE_ESM_CJS_BANNER } from 'twenty-shared/application';
import {
type LogicFunctionDriver,
type LogicFunctionExecuteParams,
type LogicFunctionExecuteResult,
type LogicFunctionDriver,
type LogicFunctionInstallPrebuiltBundleParams,
type LogicFunctionTranspileParams,
type LogicFunctionTranspileResult,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
import { isNonEmptyString } from '@sniptt/guards';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
import { LocalChildProcessRunnerService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/services/local-child-process-runner.service';
import { LocalLayerManagerService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/services/local-layer-manager.service';
import { LocalPrebuiltBundleService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/services/local-prebuilt-bundle.service';
import { type LocalDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/types/local-driver.type';
import { ConsoleListener } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/intercept-console';
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
import { HANDLER_NAME_REGEX } from 'src/engine/metadata-modules/logic-function/constants/handler.contant';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
import { LogicFunctionExecutionMode } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import {
@@ -29,179 +28,27 @@ import {
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { isLogicFunctionReadyForPrebuiltInstall } from 'src/engine/metadata-modules/logic-function/utils/is-logic-function-ready-for-prebuilt-install.util';
import { copyYarnEngineAndBuildDependencies } from 'src/engine/core-modules/application/application-package/utils/copy-yarn-engine-and-build-dependencies';
import type { LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import type { SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service';
import type { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const LAYER_BUILD_LOCK_TTL_MS = 120_000;
const LAYER_BUILD_LOCK_RETRY_MS = 500;
const LAYER_BUILD_LOCK_MAX_RETRIES = 240;
const LAYER_BUILD_READY_SENTINEL = '.twenty-layer-ready';
const PREBUILT_BUNDLE_FILE_NAME = 'prebuilt-logic-function.mjs';
const PREBUILT_CHECKSUM_FILE_NAME = 'prebuilt-bundle.checksum';
const PREBUILT_INSTALL_LOCK_TTL_MS = 180_000;
const PREBUILT_INSTALL_LOCK_RETRY_MS = 1_000;
const PREBUILT_INSTALL_LOCK_MAX_RETRIES = 180;
export interface LocalDriverOptions {
logicFunctionResourceService: LogicFunctionResourceService;
sdkClientArchiveService: SdkClientArchiveService;
cacheLockService: CacheLockService;
workspaceCacheService: WorkspaceCacheService;
}
const pathExists = async (targetPath: string): Promise<boolean> => {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
};
export { type LocalDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/types/local-driver.type';
export class LocalDriver implements LogicFunctionDriver {
private readonly logicFunctionResourceService: LogicFunctionResourceService;
private readonly sdkClientArchiveService: SdkClientArchiveService;
private readonly cacheLockService: CacheLockService;
private readonly workspaceCacheService: WorkspaceCacheService;
private readonly layerManager: LocalLayerManagerService;
private readonly childProcessRunner: LocalChildProcessRunnerService;
private readonly prebuiltBundle: LocalPrebuiltBundleService;
constructor(options: LocalDriverOptions) {
this.logicFunctionResourceService = options.logicFunctionResourceService;
this.sdkClientArchiveService = options.sdkClientArchiveService;
this.cacheLockService = options.cacheLockService;
this.workspaceCacheService = options.workspaceCacheService;
}
private getDepsLayerPath(flatApplication: FlatApplication): string {
const checksum = flatApplication.yarnLockChecksum ?? 'default';
return join(LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER, 'deps', checksum);
}
private getSdkLayerPath({
workspaceId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
}): string {
return join(
LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER,
'sdk',
`${workspaceId}-${applicationUniversalIdentifier}`,
this.layerManager = new LocalLayerManagerService(
options.cacheLockService,
options.logicFunctionResourceService,
options.sdkClientArchiveService,
options.workspaceCacheService,
);
}
private async createLayerIfNotExist({
flatApplication,
applicationUniversalIdentifier,
}: {
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
}): Promise<void> {
const depsLayerPath = this.getDepsLayerPath(flatApplication);
const depsReadySentinelPath = join(
depsLayerPath,
LAYER_BUILD_READY_SENTINEL,
);
if (await pathExists(depsReadySentinelPath)) {
return;
}
const lockKey = `local-driver-deps-layer:${flatApplication.yarnLockChecksum ?? 'default'}`;
await this.cacheLockService.withLock(
async () => {
if (await pathExists(depsReadySentinelPath)) {
return;
}
await fs.rm(depsLayerPath, { recursive: true, force: true });
await this.logicFunctionResourceService.copyDependenciesInMemory({
applicationUniversalIdentifier,
workspaceId: flatApplication.workspaceId,
inMemoryFolderPath: depsLayerPath,
});
await copyYarnEngineAndBuildDependencies(depsLayerPath);
await fs.writeFile(depsReadySentinelPath, '');
},
lockKey,
{
ttl: LAYER_BUILD_LOCK_TTL_MS,
ms: LAYER_BUILD_LOCK_RETRY_MS,
maxRetries: LAYER_BUILD_LOCK_MAX_RETRIES,
},
);
}
private async ensureSdkLayer({
flatApplication,
applicationUniversalIdentifier,
}: {
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
}): Promise<void> {
const sdkLayerPath = this.getSdkLayerPath({
workspaceId: flatApplication.workspaceId,
applicationUniversalIdentifier,
});
const sdkNodeModulesPath = join(sdkLayerPath, 'node_modules');
const sdkReadySentinelPath = join(sdkLayerPath, LAYER_BUILD_READY_SENTINEL);
if (
(await pathExists(sdkReadySentinelPath)) &&
!flatApplication.isSdkLayerStale
) {
return;
}
const lockKey = `local-driver-sdk-layer:${flatApplication.workspaceId}:${applicationUniversalIdentifier}`;
await this.cacheLockService.withLock(
async () => {
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(
flatApplication.workspaceId,
['flatApplicationMaps'],
);
const freshFlatApplication =
flatApplicationMaps.byId[flatApplication.id];
const isStale = freshFlatApplication?.isSdkLayerStale ?? true;
if ((await pathExists(sdkReadySentinelPath)) && !isStale) {
return;
}
await fs.rm(sdkLayerPath, { recursive: true, force: true });
const sdkPackagePath = join(sdkNodeModulesPath, 'twenty-client-sdk');
await this.sdkClientArchiveService.downloadAndExtractToPackage({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
targetPackagePath: sdkPackagePath,
});
await this.sdkClientArchiveService.markSdkLayerFresh({
applicationId: flatApplication.id,
workspaceId: flatApplication.workspaceId,
});
await fs.writeFile(sdkReadySentinelPath, '');
},
lockKey,
{
ttl: LAYER_BUILD_LOCK_TTL_MS,
ms: LAYER_BUILD_LOCK_RETRY_MS,
maxRetries: LAYER_BUILD_LOCK_MAX_RETRIES,
},
this.childProcessRunner = new LocalChildProcessRunnerService();
this.prebuiltBundle = new LocalPrebuiltBundleService(
options.cacheLockService,
options.logicFunctionResourceService,
);
}
@@ -241,55 +88,18 @@ export class LocalDriver implements LogicFunctionDriver {
}
}
async delete() {}
async delete(): Promise<void> {}
// Symlinks everything from the deps layer except twenty-client-sdk,
// which comes from the SDK layer (workspace-specific generated client).
private async assembleNodeModules({
sourceTemporaryDir,
flatApplication,
applicationUniversalIdentifier,
}: {
sourceTemporaryDir: string;
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
}): Promise<void> {
const depsNodeModules = join(
this.getDepsLayerPath(flatApplication),
'node_modules',
);
const sdkNodeModules = join(
this.getSdkLayerPath({
workspaceId: flatApplication.workspaceId,
applicationUniversalIdentifier,
}),
'node_modules',
);
const execNodeModules = join(sourceTemporaryDir, 'node_modules');
async installPrebuiltBundle(
params: LogicFunctionInstallPrebuiltBundleParams,
): Promise<void> {
await this.prebuiltBundle.installPrebuiltBundle(params);
}
await fs.mkdir(execNodeModules, { recursive: true });
const entries = await fs.readdir(depsNodeModules, {
withFileTypes: true,
});
const symlinkPromises = entries
.filter((entry) => entry.name !== 'twenty-client-sdk')
.map((entry) =>
fs.symlink(
join(depsNodeModules, entry.name),
join(execNodeModules, entry.name),
entry.isDirectory() ? 'dir' : 'file',
),
);
await Promise.all(symlinkPromises);
await fs.symlink(
join(sdkNodeModules, 'twenty-client-sdk'),
join(execNodeModules, 'twenty-client-sdk'),
'dir',
);
async getInstalledBundleChecksum(
flatLogicFunction: FlatLogicFunction,
): Promise<string | null> {
return this.prebuiltBundle.getInstalledBundleChecksum(flatLogicFunction);
}
async execute({
@@ -313,23 +123,22 @@ export class LocalDriver implements LogicFunctionDriver {
);
}
await this.createLayerIfNotExist({
await this.layerManager.ensureDepsLayer({
flatApplication,
applicationUniversalIdentifier,
});
await this.ensureSdkLayer({
await this.layerManager.ensureSdkLayer({
flatApplication,
applicationUniversalIdentifier,
});
const startTime = Date.now();
const temporaryDirManager = new TemporaryDirManager();
try {
const { sourceTemporaryDir } = await temporaryDirManager.init();
await this.assembleNodeModules({
await this.childProcessRunner.assembleNodeModules({
sourceTemporaryDir,
flatApplication,
applicationUniversalIdentifier,
@@ -337,7 +146,7 @@ export class LocalDriver implements LogicFunctionDriver {
const inMemoryBuiltHandlerPath =
executionMode === LogicFunctionExecutionMode.PREBUILT
? await this.copyPrebuiltBundleIntoExecutionDir({
? await this.prebuiltBundle.copyPrebuiltBundleIntoExecutionDir({
flatLogicFunction,
sourceTemporaryDir,
})
@@ -349,7 +158,6 @@ export class LocalDriver implements LogicFunctionDriver {
});
let logs = '';
const consoleListener = new ConsoleListener();
consoleListener.intercept((type, args) => {
@@ -382,14 +190,14 @@ export class LocalDriver implements LogicFunctionDriver {
});
try {
const runnerPath = await this.writeBootstrapRunner({
const runnerPath = await this.childProcessRunner.writeBootstrapRunner({
dir: sourceTemporaryDir,
builtFileAbsPath: inMemoryBuiltHandlerPath,
handlerName: flatLogicFunction.handlerName,
});
const { ok, result, error, stack, stdout, stderr } =
await this.runChildWithEnv({
await this.childProcessRunner.runChildWithEnv({
runnerPath,
env: env ?? {},
payload,
@@ -440,269 +248,4 @@ export class LocalDriver implements LogicFunctionDriver {
await temporaryDirManager.clean();
}
}
async writeBootstrapRunner({
dir,
builtFileAbsPath,
handlerName,
}: {
dir: string;
builtFileAbsPath: string;
handlerName: string;
}) {
if (!HANDLER_NAME_REGEX.test(handlerName)) {
throw new Error(
`Invalid handlerName "${handlerName}": must be a valid JavaScript identifier or dotted path`,
);
}
const runnerPath = join(dir, '__runner.cjs');
const code = `
// Auto-generated. Do not edit.
const { pathToFileURL } = require('node:url');
(async () => {
try {
const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)});
const mod = await import(builtUrl.href);
if (typeof mod.${handlerName} !== 'function') {
throw new Error('Export "${handlerName}" not found in function bundle');
}
let payload = undefined;
if (process.send) {
process.on('message', async (msg) => {
if (!msg || msg.type !== 'run') return;
try {
const out = await mod.${handlerName}(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
} catch (err) {
process.send && process.send({ ok: false, error: String(err), stack: err?.stack });
process.exit(1);
}
});
} else {
// Fallback: read payload from argv[2] (JSON) and print to stdout
const json = process.argv[2];
payload = json ? JSON.parse(json) : undefined;
const out = await mod.${handlerName}(payload);
process.stdout.write(JSON.stringify({ ok: true, result: out }));
process.exit(0);
}
} catch (err) {
const msg = String(err);
if (process.send) {
process.send({ ok: false, error: msg, stack: err?.stack });
} else {
process.stdout.write(msg);
}
process.exit(1);
}
})();
`;
await fs.writeFile(runnerPath, code, 'utf8');
return runnerPath;
}
runChildWithEnv(options: {
runnerPath: string;
env: Record<string, string>;
payload: unknown;
timeoutMs: number;
}) {
const { runnerPath, env, payload, timeoutMs } = options;
return new Promise<{
ok: boolean;
result?: unknown;
error?: string;
stack?: string;
stdout: string;
stderr: string;
}>((resolve) => {
// Strip NODE_OPTIONS to prevent tsx loader from being inherited
const { NODE_OPTIONS: _n1, ...cleanProcessEnv } = process.env;
const { NODE_OPTIONS: _n2, ...cleanUserEnv } = env;
const child = spawn(process.execPath, [runnerPath], {
env: { ...cleanProcessEnv, ...cleanUserEnv },
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let stdout = '';
let stderr = '';
let settled = false;
child.stdout?.on('data', (d) => (stdout += String(d)));
child.stderr?.on('data', (d) => (stderr += String(d)));
child.on(
'message',
(
msg:
| {
ok: true;
result?: unknown;
stdout?: string;
stderr?: string;
}
| {
ok: false;
error: string;
stack?: string;
stdout?: string;
stderr?: string;
},
) => {
if (settled) return;
settled = true;
resolve({ ...msg, stdout, stderr });
},
);
child.on('exit', (code) => {
if (settled) return;
settled = true;
if (code === 0) {
resolve({ ok: true, stdout, stderr });
} else {
resolve({
ok: false,
error: `Exited with code ${code}`,
stdout,
stderr,
});
}
});
const t = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({
ok: false,
error: `Timed out after ${timeoutMs}ms`,
stdout,
stderr,
});
}, timeoutMs);
child.send?.({ type: 'run', payload });
child.on('close', () => clearTimeout(t));
});
}
private getPrebuiltBundleDir(flatLogicFunction: FlatLogicFunction): string {
return join(
LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER,
'prebuilt',
flatLogicFunction.id,
);
}
private getInstalledBundlePath(flatLogicFunction: FlatLogicFunction): string {
return join(
this.getPrebuiltBundleDir(flatLogicFunction),
PREBUILT_BUNDLE_FILE_NAME,
);
}
private async copyPrebuiltBundleIntoExecutionDir({
flatLogicFunction,
sourceTemporaryDir,
}: {
flatLogicFunction: FlatLogicFunction;
sourceTemporaryDir: string;
}): Promise<string> {
const installedBundlePath = this.getInstalledBundlePath(flatLogicFunction);
const localBundlePath = join(sourceTemporaryDir, PREBUILT_BUNDLE_FILE_NAME);
await fs.copyFile(installedBundlePath, localBundlePath);
return localBundlePath;
}
private getInstalledChecksumPath(
flatLogicFunction: FlatLogicFunction,
): string {
return join(
this.getPrebuiltBundleDir(flatLogicFunction),
PREBUILT_CHECKSUM_FILE_NAME,
);
}
private getPrebuiltInstallLockKey(flatLogicFunction: FlatLogicFunction) {
return `local-install:${flatLogicFunction.id}`;
}
async installPrebuiltBundle({
flatLogicFunction,
applicationUniversalIdentifier,
}: LogicFunctionInstallPrebuiltBundleParams): Promise<void> {
if (!isNonEmptyString(flatLogicFunction.checksum)) {
throw new LogicFunctionException(
`Cannot install prebuilt bundle for function '${flatLogicFunction.id}' without a checksum`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
);
}
const checksum = flatLogicFunction.checksum;
await this.cacheLockService.withLock(
async () => {
const prebuiltDir = this.getPrebuiltBundleDir(flatLogicFunction);
await fs.mkdir(prebuiltDir, { recursive: true });
await this.logicFunctionResourceService.copyBuiltCodeInMemory({
workspaceId: flatLogicFunction.workspaceId,
applicationUniversalIdentifier,
builtHandlerPath: flatLogicFunction.builtHandlerPath,
inMemoryDestinationPath: prebuiltDir,
});
const downloadedPath = join(
prebuiltDir,
flatLogicFunction.builtHandlerPath,
);
const targetPath = this.getInstalledBundlePath(flatLogicFunction);
if (downloadedPath !== targetPath) {
await fs.mkdir(dirname(targetPath), { recursive: true });
await fs.rename(downloadedPath, targetPath);
}
await fs.writeFile(
this.getInstalledChecksumPath(flatLogicFunction),
checksum,
'utf8',
);
},
this.getPrebuiltInstallLockKey(flatLogicFunction),
{
ttl: PREBUILT_INSTALL_LOCK_TTL_MS,
ms: PREBUILT_INSTALL_LOCK_RETRY_MS,
maxRetries: PREBUILT_INSTALL_LOCK_MAX_RETRIES,
},
);
}
async getInstalledBundleChecksum(
flatLogicFunction: FlatLogicFunction,
): Promise<string | null> {
try {
const checksum = await fs.readFile(
this.getInstalledChecksumPath(flatLogicFunction),
'utf8',
);
return checksum.trim() || null;
} catch {
return null;
}
}
}
@@ -0,0 +1,12 @@
export const LAYER_BUILD_LOCK_TTL_MS = 120_000;
export const LAYER_BUILD_LOCK_RETRY_MS = 500;
export const LAYER_BUILD_LOCK_MAX_RETRIES = 240;
export const LAYER_BUILD_READY_SENTINEL = '.twenty-layer-ready';
export const PREBUILT_BUNDLE_FILE_NAME = 'prebuilt-logic-function.mjs';
export const PREBUILT_CHECKSUM_FILE_NAME = 'prebuilt-bundle.checksum';
export const PREBUILT_INSTALL_LOCK_TTL_MS = 180_000;
export const PREBUILT_INSTALL_LOCK_RETRY_MS = 1_000;
export const PREBUILT_INSTALL_LOCK_MAX_RETRIES = 180;
@@ -0,0 +1,214 @@
import { promises as fs } from 'fs';
import { spawn } from 'node:child_process';
import { join } from 'path';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { getLocalDepsLayerPath } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-deps-layer-path.util';
import { getLocalSdkLayerPath } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-sdk-layer-path.util';
import { HANDLER_NAME_REGEX } from 'src/engine/metadata-modules/logic-function/constants/handler.contant';
export class LocalChildProcessRunnerService {
// Symlinks everything from the deps layer except twenty-client-sdk,
// which comes from the SDK layer (workspace-specific generated client).
async assembleNodeModules({
sourceTemporaryDir,
flatApplication,
applicationUniversalIdentifier,
}: {
sourceTemporaryDir: string;
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
}): Promise<void> {
const depsNodeModules = join(
getLocalDepsLayerPath(flatApplication),
'node_modules',
);
const sdkNodeModules = join(
getLocalSdkLayerPath({
workspaceId: flatApplication.workspaceId,
applicationUniversalIdentifier,
}),
'node_modules',
);
const execNodeModules = join(sourceTemporaryDir, 'node_modules');
await fs.mkdir(execNodeModules, { recursive: true });
const entries = await fs.readdir(depsNodeModules, {
withFileTypes: true,
});
const symlinkPromises = entries
.filter((entry) => entry.name !== 'twenty-client-sdk')
.map((entry) =>
fs.symlink(
join(depsNodeModules, entry.name),
join(execNodeModules, entry.name),
entry.isDirectory() ? 'dir' : 'file',
),
);
await Promise.all(symlinkPromises);
await fs.symlink(
join(sdkNodeModules, 'twenty-client-sdk'),
join(execNodeModules, 'twenty-client-sdk'),
'dir',
);
}
async writeBootstrapRunner({
dir,
builtFileAbsPath,
handlerName,
}: {
dir: string;
builtFileAbsPath: string;
handlerName: string;
}) {
if (!HANDLER_NAME_REGEX.test(handlerName)) {
throw new Error(
`Invalid handlerName "${handlerName}": must be a valid JavaScript identifier or dotted path`,
);
}
const runnerPath = join(dir, '__runner.cjs');
const code = `
// Auto-generated. Do not edit.
const { pathToFileURL } = require('node:url');
(async () => {
try {
const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)});
const mod = await import(builtUrl.href);
if (typeof mod.${handlerName} !== 'function') {
throw new Error('Export "${handlerName}" not found in function bundle');
}
let payload = undefined;
if (process.send) {
process.on('message', async (msg) => {
if (!msg || msg.type !== 'run') return;
try {
const out = await mod.${handlerName}(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
} catch (err) {
process.send && process.send({ ok: false, error: String(err), stack: err?.stack });
process.exit(1);
}
});
} else {
// Fallback: read payload from argv[2] (JSON) and print to stdout
const json = process.argv[2];
payload = json ? JSON.parse(json) : undefined;
const out = await mod.${handlerName}(payload);
process.stdout.write(JSON.stringify({ ok: true, result: out }));
process.exit(0);
}
} catch (err) {
const msg = String(err);
if (process.send) {
process.send({ ok: false, error: msg, stack: err?.stack });
} else {
process.stdout.write(msg);
}
process.exit(1);
}
})();
`;
await fs.writeFile(runnerPath, code, 'utf8');
return runnerPath;
}
runChildWithEnv(options: {
runnerPath: string;
env: Record<string, string>;
payload: unknown;
timeoutMs: number;
}) {
const { runnerPath, env, payload, timeoutMs } = options;
return new Promise<{
ok: boolean;
result?: unknown;
error?: string;
stack?: string;
stdout: string;
stderr: string;
}>((resolve) => {
// Strip NODE_OPTIONS to prevent tsx loader from being inherited
const { NODE_OPTIONS: _n1, ...cleanProcessEnv } = process.env;
const { NODE_OPTIONS: _n2, ...cleanUserEnv } = env;
const child = spawn(process.execPath, [runnerPath], {
env: { ...cleanProcessEnv, ...cleanUserEnv },
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let stdout = '';
let stderr = '';
let settled = false;
child.stdout?.on('data', (d) => (stdout += String(d)));
child.stderr?.on('data', (d) => (stderr += String(d)));
child.on(
'message',
(
msg:
| {
ok: true;
result?: unknown;
stdout?: string;
stderr?: string;
}
| {
ok: false;
error: string;
stack?: string;
stdout?: string;
stderr?: string;
},
) => {
if (settled) return;
settled = true;
resolve({ ...msg, stdout, stderr });
},
);
child.on('exit', (code) => {
if (settled) return;
settled = true;
if (code === 0) {
resolve({ ok: true, stdout, stderr });
} else {
resolve({
ok: false,
error: `Exited with code ${code}`,
stdout,
stderr,
});
}
});
const t = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({
ok: false,
error: `Timed out after ${timeoutMs}ms`,
stdout,
stderr,
});
}, timeoutMs);
child.send?.({ type: 'run', payload });
child.on('close', () => clearTimeout(t));
});
}
}
@@ -0,0 +1,135 @@
import { promises as fs } from 'fs';
import { join } from 'path';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { copyYarnEngineAndBuildDependencies } from 'src/engine/core-modules/application/application-package/utils/copy-yarn-engine-and-build-dependencies';
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import {
LAYER_BUILD_LOCK_MAX_RETRIES,
LAYER_BUILD_LOCK_RETRY_MS,
LAYER_BUILD_LOCK_TTL_MS,
LAYER_BUILD_READY_SENTINEL,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/constants/local-driver.constant';
import { getLocalDepsLayerPath } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-deps-layer-path.util';
import { getLocalSdkLayerPath } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-sdk-layer-path.util';
import { pathExists } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/path-exists.util';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import { type SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
type LayerAppContext = {
flatApplication: FlatApplication;
applicationUniversalIdentifier: string;
};
export class LocalLayerManagerService {
constructor(
private readonly cacheLockService: CacheLockService,
private readonly logicFunctionResourceService: LogicFunctionResourceService,
private readonly sdkClientArchiveService: SdkClientArchiveService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async ensureDepsLayer({
flatApplication,
applicationUniversalIdentifier,
}: LayerAppContext): Promise<void> {
const depsLayerPath = getLocalDepsLayerPath(flatApplication);
const depsReadySentinelPath = join(
depsLayerPath,
LAYER_BUILD_READY_SENTINEL,
);
if (await pathExists(depsReadySentinelPath)) {
return;
}
const lockKey = `local-driver-deps-layer:${flatApplication.yarnLockChecksum ?? 'default'}`;
await this.cacheLockService.withLock(
async () => {
if (await pathExists(depsReadySentinelPath)) {
return;
}
await fs.rm(depsLayerPath, { recursive: true, force: true });
await this.logicFunctionResourceService.copyDependenciesInMemory({
applicationUniversalIdentifier,
workspaceId: flatApplication.workspaceId,
inMemoryFolderPath: depsLayerPath,
});
await copyYarnEngineAndBuildDependencies(depsLayerPath);
await fs.writeFile(depsReadySentinelPath, '');
},
lockKey,
{
ttl: LAYER_BUILD_LOCK_TTL_MS,
ms: LAYER_BUILD_LOCK_RETRY_MS,
maxRetries: LAYER_BUILD_LOCK_MAX_RETRIES,
},
);
}
async ensureSdkLayer({
flatApplication,
applicationUniversalIdentifier,
}: LayerAppContext): Promise<void> {
const sdkLayerPath = getLocalSdkLayerPath({
workspaceId: flatApplication.workspaceId,
applicationUniversalIdentifier,
});
const sdkNodeModulesPath = join(sdkLayerPath, 'node_modules');
const sdkReadySentinelPath = join(sdkLayerPath, LAYER_BUILD_READY_SENTINEL);
if (
(await pathExists(sdkReadySentinelPath)) &&
!flatApplication.isSdkLayerStale
) {
return;
}
const lockKey = `local-driver-sdk-layer:${flatApplication.workspaceId}:${applicationUniversalIdentifier}`;
await this.cacheLockService.withLock(
async () => {
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(
flatApplication.workspaceId,
['flatApplicationMaps'],
);
const freshFlatApplication =
flatApplicationMaps.byId[flatApplication.id];
const isStale = freshFlatApplication?.isSdkLayerStale ?? true;
if ((await pathExists(sdkReadySentinelPath)) && !isStale) {
return;
}
await fs.rm(sdkLayerPath, { recursive: true, force: true });
const sdkPackagePath = join(sdkNodeModulesPath, 'twenty-client-sdk');
await this.sdkClientArchiveService.downloadAndExtractToPackage({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
targetPackagePath: sdkPackagePath,
});
await this.sdkClientArchiveService.markSdkLayerFresh({
applicationId: flatApplication.id,
workspaceId: flatApplication.workspaceId,
});
await fs.writeFile(sdkReadySentinelPath, '');
},
lockKey,
{
ttl: LAYER_BUILD_LOCK_TTL_MS,
ms: LAYER_BUILD_LOCK_RETRY_MS,
maxRetries: LAYER_BUILD_LOCK_MAX_RETRIES,
},
);
}
}
@@ -0,0 +1,113 @@
import { promises as fs } from 'fs';
import { dirname, join } from 'path';
import { isNonEmptyString } from '@sniptt/guards';
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import {
PREBUILT_BUNDLE_FILE_NAME,
PREBUILT_INSTALL_LOCK_MAX_RETRIES,
PREBUILT_INSTALL_LOCK_RETRY_MS,
PREBUILT_INSTALL_LOCK_TTL_MS,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/constants/local-driver.constant';
import {
getLocalInstalledBundlePath,
getLocalInstalledChecksumPath,
getLocalPrebuiltBundleDir,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-prebuilt-bundle-paths.util';
import { type LogicFunctionInstallPrebuiltBundleParams } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
export class LocalPrebuiltBundleService {
constructor(
private readonly cacheLockService: CacheLockService,
private readonly logicFunctionResourceService: LogicFunctionResourceService,
) {}
async installPrebuiltBundle({
flatLogicFunction,
applicationUniversalIdentifier,
}: LogicFunctionInstallPrebuiltBundleParams): Promise<void> {
if (!isNonEmptyString(flatLogicFunction.checksum)) {
throw new LogicFunctionException(
`Cannot install prebuilt bundle for function '${flatLogicFunction.id}' without a checksum`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
);
}
const checksum = flatLogicFunction.checksum;
await this.cacheLockService.withLock(
async () => {
const prebuiltDir = getLocalPrebuiltBundleDir(flatLogicFunction);
await fs.mkdir(prebuiltDir, { recursive: true });
await this.logicFunctionResourceService.copyBuiltCodeInMemory({
workspaceId: flatLogicFunction.workspaceId,
applicationUniversalIdentifier,
builtHandlerPath: flatLogicFunction.builtHandlerPath,
inMemoryDestinationPath: prebuiltDir,
});
const downloadedPath = join(
prebuiltDir,
flatLogicFunction.builtHandlerPath,
);
const targetPath = getLocalInstalledBundlePath(flatLogicFunction);
if (downloadedPath !== targetPath) {
await fs.mkdir(dirname(targetPath), { recursive: true });
await fs.rename(downloadedPath, targetPath);
}
await fs.writeFile(
getLocalInstalledChecksumPath(flatLogicFunction),
checksum,
'utf8',
);
},
`local-install:${flatLogicFunction.id}`,
{
ttl: PREBUILT_INSTALL_LOCK_TTL_MS,
ms: PREBUILT_INSTALL_LOCK_RETRY_MS,
maxRetries: PREBUILT_INSTALL_LOCK_MAX_RETRIES,
},
);
}
async getInstalledBundleChecksum(
flatLogicFunction: FlatLogicFunction,
): Promise<string | null> {
try {
const checksum = await fs.readFile(
getLocalInstalledChecksumPath(flatLogicFunction),
'utf8',
);
return checksum.trim() || null;
} catch {
return null;
}
}
async copyPrebuiltBundleIntoExecutionDir({
flatLogicFunction,
sourceTemporaryDir,
}: {
flatLogicFunction: FlatLogicFunction;
sourceTemporaryDir: string;
}): Promise<string> {
const installedBundlePath = getLocalInstalledBundlePath(flatLogicFunction);
const localBundlePath = join(sourceTemporaryDir, PREBUILT_BUNDLE_FILE_NAME);
await fs.copyFile(installedBundlePath, localBundlePath);
return localBundlePath;
}
}
@@ -0,0 +1,11 @@
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import { type SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
export interface LocalDriverOptions {
logicFunctionResourceService: LogicFunctionResourceService;
sdkClientArchiveService: SdkClientArchiveService;
cacheLockService: CacheLockService;
workspaceCacheService: WorkspaceCacheService;
}
@@ -0,0 +1,19 @@
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
import { getLocalDepsLayerPath } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-deps-layer-path.util';
describe('getLocalDepsLayerPath', () => {
it('joins the tmpdir folder, the deps segment and the yarnLockChecksum', () => {
expect(
getLocalDepsLayerPath({ yarnLockChecksum: 'abc123' } as FlatApplication),
).toBe(`${LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER}/deps/abc123`);
});
it('falls back to default when yarnLockChecksum is undefined', () => {
expect(
getLocalDepsLayerPath({
yarnLockChecksum: undefined,
} as unknown as FlatApplication),
).toBe(`${LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER}/deps/default`);
});
});
@@ -0,0 +1,13 @@
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
import { getLocalSdkLayerPath } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/utils/get-local-sdk-layer-path.util';
describe('getLocalSdkLayerPath', () => {
it('joins the tmpdir folder, the sdk segment and "<workspaceId>-<applicationUniversalIdentifier>"', () => {
expect(
getLocalSdkLayerPath({
workspaceId: 'ws-1',
applicationUniversalIdentifier: 'app-2',
}),
).toBe(`${LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER}/sdk/ws-1-app-2`);
});
});
@@ -0,0 +1,12 @@
import { join } from 'path';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
export const getLocalDepsLayerPath = (
flatApplication: FlatApplication,
): string => {
const checksum = flatApplication.yarnLockChecksum ?? 'default';
return join(LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER, 'deps', checksum);
};
@@ -0,0 +1,26 @@
import { join } from 'path';
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
import {
PREBUILT_BUNDLE_FILE_NAME,
PREBUILT_CHECKSUM_FILE_NAME,
} from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/local/constants/local-driver.constant';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
export const getLocalPrebuiltBundleDir = (
flatLogicFunction: FlatLogicFunction,
): string =>
join(LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER, 'prebuilt', flatLogicFunction.id);
export const getLocalInstalledBundlePath = (
flatLogicFunction: FlatLogicFunction,
): string =>
join(getLocalPrebuiltBundleDir(flatLogicFunction), PREBUILT_BUNDLE_FILE_NAME);
export const getLocalInstalledChecksumPath = (
flatLogicFunction: FlatLogicFunction,
): string =>
join(
getLocalPrebuiltBundleDir(flatLogicFunction),
PREBUILT_CHECKSUM_FILE_NAME,
);
@@ -0,0 +1,16 @@
import { join } from 'path';
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
export const getLocalSdkLayerPath = ({
workspaceId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
}): string =>
join(
LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER,
'sdk',
`${workspaceId}-${applicationUniversalIdentifier}`,
);
@@ -0,0 +1,11 @@
import { promises as fs } from 'fs';
export const pathExists = async (targetPath: string): Promise<boolean> => {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
};