1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347)

This PR adds 2 columns handlerPath and handlerName in serverlessFunction
to locate the entrypoint of a serverless in a codebase

It adds the following decorators in twenty-sdk:
- ServerlessFunction
- DatabaseEventTrigger
- RouteTrigger
- CronTrigger
- ApplicationVariable

It still supports deprecated entity.manifest.jsonc 

Overall code needs to be cleaned a little bit, but it should work
properly so you can try to test if the DEVX fits your needs

See updates in hello-world application

```typescript
import axios from 'axios';
import {
  DatabaseEventTrigger,
  ServerlessFunction,
  RouteTrigger,
  CronTrigger,
  ApplicationVariable,
} from 'twenty-sdk';

@ApplicationVariable({
  universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
  key: 'TWENTY_API_KEY',
  description: 'Twenty API Key',
  isSecret: true,
})
@DatabaseEventTrigger({
  universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
  eventName: 'person.created',
})
@RouteTrigger({
  universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
  path: '/post-card/create',
  httpMethod: 'GET',
  isAuthRequired: false,
})
@CronTrigger({
  universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
  pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
class CreateNewPostCard {
  main = async (params: { recipient: string }): Promise<string> => {
    const { recipient } = params;

    const options = {
      method: 'POST',
      url: 'http://localhost:3000/rest/postCards',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
      },
      data: { name: recipient ?? 'Unknown' },
    };

    try {
      const { data } = await axios.request(options);

      console.log(`New post card to "${recipient}" created`);

      return data;
    } catch (error) {
      console.error(error);
      throw error;
    }
  };
}

export const createNewPostCardHandler = new CreateNewPostCard().main;

```


### [edit] V2 

After the v1 proposal, I see that using a class method to define the
serverless function handler is pretty confusing. Lets leave
serverlessFunction configuration decorators on the class, but move the
handler like before. Here is the v2 hello-world serverless function:

```typescript
import axios from 'axios';
import {
  DatabaseEventTrigger,
  ServerlessFunction,
  RouteTrigger,
  CronTrigger,
  ApplicationVariable,
} from 'twenty-sdk';

@ApplicationVariable({
  universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
  key: 'TWENTY_API_KEY',
  description: 'Twenty API Key',
  isSecret: true,
})
@DatabaseEventTrigger({
  universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
  eventName: 'person.created',
})
@RouteTrigger({
  universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
  path: '/post-card/create',
  httpMethod: 'GET',
  isAuthRequired: false,
})
@CronTrigger({
  universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
  pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
export class ServerlessFunctionDefinition {}

export const main = async (params: { recipient: string }): Promise<string> => {
  const { recipient } = params;

  const options = {
    method: 'POST',
    url: 'http://localhost:3000/rest/postCards',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
    },
    data: { name: recipient ?? 'Unknown' },
  };

  try {
    const { data } = await axios.request(options);

    console.log(`New post card to "${recipient}" created`);

    return data;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

```


### [edit] V3

After the v2 proposal, we don't really like decorators on empty classes.
We decided to go with a Vercel approach with a config constant

```typescript
import axios from 'axios';
import { ServerlessFunctionConfig } from 'twenty-sdk';

export const main = async (params: { recipient: string }): Promise<string> => {
  const { recipient } = params;

  const options = {
    method: 'POST',
    url: 'http://localhost:3000/rest/postCards',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
    },
    data: { name: recipient ?? 'Unknown' },
  };

  try {
    const { data } = await axios.request(options);

    console.log(`New post card to "${recipient}" created`);

    return data;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

export const config: ServerlessFunctionConfig = {
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
  routeTriggers: [
  {
    universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
    path: '/post-card/create',
    httpMethod: 'GET',
    isAuthRequired: false,
  }
  ],
  cronTriggers: [
    {
      universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
      pattern: '0 0 1 1 *', // Every year 1st of January
    }
  ],
  databaseEventTriggers: [
  {
    universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
    eventName: 'person.created',
   }
  ]
}

```
This commit is contained in:
martmull
2025-10-29 17:51:43 +01:00
committed by GitHub
parent 75ed5cb3a2
commit a6cc80eedd
81 changed files with 2070 additions and 1044 deletions
@@ -6,20 +6,23 @@ export const handler = async (event) => {
const mainPath = `/tmp/${randomId}.mjs`;
// eslint-disable-next-line no-undef
const oldProcessEnv = { ...process.env };
try {
const { code, params, env } = event;
const { code, params, env, handlerName } = event;
await fs.writeFile(mainPath, code, 'utf8');
// eslint-disable-next-line no-undef
process.env = { ...process.env, ...(env ?? {}) };
const mainFile = await import(mainPath);
return await mainFile.main(params);
return await mainFile[handlerName](params);
} finally {
await fs.rm(mainPath, { force: true });
// eslint-disable-next-line no-undef
process.env = oldProcessEnv;
}
};
@@ -51,6 +51,13 @@ import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/bu
const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
type LambdaDriverExecutorPayload = {
code: string;
params: object;
env: Record<string, string>;
handlerName: string;
};
export interface LambdaDriverOptions extends LambdaClientConfig {
fileStorageService: FileStorageService;
region: string;
@@ -326,8 +333,10 @@ export class LambdaDriver implements ServerlessDriver {
let builtBundleFilePath = '';
try {
builtBundleFilePath =
await buildServerlessFunctionInMemory(sourceTemporaryDir);
builtBundleFilePath = await buildServerlessFunctionInMemory({
sourceTemporaryDir,
handlerPath: serverlessFunction.handlerPath,
});
} catch (error) {
return formatBuildError(error, startTime);
}
@@ -336,10 +345,11 @@ export class LambdaDriver implements ServerlessDriver {
'utf-8',
);
const executorPayload = {
const executorPayload: LambdaDriverExecutorPayload = {
params: payload,
code: compiledCode,
env: buildEnvVar(serverlessFunction),
handlerName: serverlessFunction.handlerName,
};
const params: InvokeCommandInput = {
@@ -61,27 +61,6 @@ export class LocalDriver implements ServerlessDriver {
await this.createLayerIfNotExists(serverlessFunction);
}
private async executeWithTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Task timed out after ${timeoutMs / 1_000} seconds`));
}, timeoutMs);
fn()
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
@@ -109,8 +88,10 @@ export class LocalDriver implements ServerlessDriver {
let builtBundleFilePath = '';
try {
builtBundleFilePath =
await buildServerlessFunctionInMemory(sourceTemporaryDir);
builtBundleFilePath = await buildServerlessFunctionInMemory({
sourceTemporaryDir,
handlerPath: serverlessFunction.handlerPath,
});
} catch (error) {
return formatBuildError(error, startTime);
}
@@ -164,10 +145,11 @@ export class LocalDriver implements ServerlessDriver {
});
try {
const runnerPath = await this.writeBootstrapRunner(
sourceTemporaryDir,
builtBundleFilePath,
);
const runnerPath = await this.writeBootstrapRunner({
dir: sourceTemporaryDir,
builtFileAbsPath: builtBundleFilePath,
handlerName: serverlessFunction.handlerName,
});
const { ok, result, error, stack, stdout, stderr } =
await this.runChildWithEnv({
@@ -222,7 +204,15 @@ export class LocalDriver implements ServerlessDriver {
}
}
async writeBootstrapRunner(dir: string, builtFileAbsPath: string) {
async writeBootstrapRunner({
dir,
builtFileAbsPath,
handlerName,
}: {
dir: string;
builtFileAbsPath: string;
handlerName: string;
}) {
const runnerPath = join(dir, '__runner.cjs');
const code = `
// Auto-generated. Do not edit.
@@ -232,8 +222,8 @@ export class LocalDriver implements ServerlessDriver {
try {
const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)});
const mod = await import(builtUrl.href);
if (typeof mod.main !== 'function') {
throw new Error('Export "main" not found in serverless bundle');
if (typeof mod.${handlerName} !== 'function') {
throw new Error('Export "${handlerName}" not found in serverless bundle');
}
let payload = undefined;
@@ -241,7 +231,7 @@ export class LocalDriver implements ServerlessDriver {
process.on('message', async (msg) => {
if (!msg || msg.type !== 'run') return;
try {
const out = await mod.main(msg.payload);
const out = await mod.${handlerName}(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
} catch (err) {
@@ -253,7 +243,7 @@ export class LocalDriver implements ServerlessDriver {
// 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.main(payload);
const out = await mod.${handlerName}(payload);
console.log(JSON.stringify({ ok: true, result: out }));
process.exit(0);
}
@@ -2,10 +2,14 @@ import { join } from 'path';
import { build } from 'esbuild';
export const buildServerlessFunctionInMemory = async (
sourceTemporaryDir: string,
) => {
const entryFilePath = join(sourceTemporaryDir, 'src', 'index.ts');
export const buildServerlessFunctionInMemory = async ({
sourceTemporaryDir,
handlerPath,
}: {
sourceTemporaryDir: string;
handlerPath: string;
}) => {
const entryFilePath = join(sourceTemporaryDir, handlerPath);
const builtBundleFilePath = join(sourceTemporaryDir, 'dist', 'main.mjs');