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
@@ -5,32 +5,19 @@ import inquirer from 'inquirer';
import path from 'path';
import camelcase from 'lodash.camelcase';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { HTTPMethod } from '../types/config.types';
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
import { BASE_SCHEMAS_PATH } from '../constants/constants-path';
import { getDecoratedClass } from '../utils/get-decorated-class';
import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class';
import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
const ROOT_FOLDER = 'src';
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
SERVERLESS_FUNCTION = 'serverlessFunction',
TRIGGER = 'trigger',
}
const getFolderName = (entity: SyncableEntity) => {
switch (entity) {
case SyncableEntity.AGENT:
return 'agents';
case SyncableEntity.OBJECT:
return 'objects';
case SyncableEntity.SERVERLESS_FUNCTION:
return 'serverlessFunctions';
default:
throw new Error(`Unknown entity type: ${entity}`);
}
};
export const isSyncableEntity = (value: string): value is SyncableEntity => {
return Object.values(SyncableEntity).includes(value as SyncableEntity);
};
@@ -38,22 +25,12 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => {
export class AppAddCommand {
async execute(entityType?: SyncableEntity): Promise<void> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER);
await fs.ensureDir(appPath);
const entity = entityType ?? (await this.getEntity());
const appExists = await fs.pathExists(appPath);
if (!appExists) {
console.error(chalk.red('App does not exist'));
process.exit(1);
}
if (entity === SyncableEntity.TRIGGER) {
await this.addTriggerToServerlessFunction(appPath);
return;
}
const entityName = await this.getEntityName(entity);
const entityData = await this.getEntityToCreateData(entity, entityName);
@@ -64,7 +41,7 @@ export class AppAddCommand {
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedObject = getDecoratedClass({
const decoratedObject = getObjectMetadataDecoratedClass({
data: entityData,
name: entityName,
});
@@ -74,18 +51,20 @@ export class AppAddCommand {
return;
}
const folderName = getFolderName(entity);
if (entity === SyncableEntity.SERVERLESS_FUNCTION) {
const objectFileName = `${camelcase(entityName)}.ts`;
const entitiesDir = path.join(appPath, folderName, entityName);
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
name: entityName,
});
await fs.ensureDir(entitiesDir);
await fs.writeFile(
path.join(appPath, objectFileName),
decoratedServerlessFunction,
);
await writeJsoncFile(
path.join(entitiesDir, `${entity}.manifest.jsonc`),
entityData,
);
await this.addEntityInitFiles(entity, entitiesDir);
return;
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
@@ -123,11 +102,7 @@ export class AppAddCommand {
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [
SyncableEntity.SERVERLESS_FUNCTION,
SyncableEntity.OBJECT,
SyncableEntity.TRIGGER,
],
choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.OBJECT],
},
]);
@@ -212,178 +187,4 @@ export class AppAddCommand {
return entityToCreateData;
}
private async addTriggerToServerlessFunction(appPath: string) {
const serverlessFunctionsDir = path.join(appPath, 'serverlessFunctions');
if (!(await fs.pathExists(serverlessFunctionsDir))) {
console.error(chalk.red('No serverless functions found in this app'));
process.exit(1);
}
const serverlessFunctions = await fs.readdir(serverlessFunctionsDir);
if (serverlessFunctions.length === 0) {
console.error(chalk.red('No serverless functions found in this app'));
process.exit(1);
}
const { serverlessFunctionName } = await inquirer.prompt<{
serverlessFunctionName: string;
}>([
{
type: 'list',
name: 'serverlessFunctionName',
message: 'Select a serverless function to add a trigger to:',
choices: serverlessFunctions,
},
]);
const { triggerType } = await inquirer.prompt<{ triggerType: string }>([
{
type: 'list',
name: 'triggerType',
message: 'Select the type of trigger:',
choices: ['databaseEvent', 'cron', 'route'],
},
]);
let triggerData: any;
if (triggerType === 'databaseEvent') {
triggerData = await this.createDatabaseEventTrigger();
} else if (triggerType === 'cron') {
triggerData = await this.createCronTrigger();
} else if (triggerType === 'route') {
triggerData = await this.createRouteTrigger();
}
const manifestPath = path.join(
serverlessFunctionsDir,
serverlessFunctionName,
'serverlessFunction.manifest.jsonc',
);
const manifest = await parseJsoncFile(manifestPath);
if (!manifest.triggers) {
manifest.triggers = [];
}
manifest.triggers.push(triggerData);
await writeJsoncFile(manifestPath, manifest);
console.log(
chalk.green(`✅ Trigger added successfully to ${serverlessFunctionName}`),
);
}
private async createDatabaseEventTrigger() {
const uuid = randomUUID();
const { eventName } = await inquirer.prompt<{ eventName: string }>([
{
type: 'input',
name: 'eventName',
message:
'Enter the database event name (e.g. company.created, *.updated, person.*):',
validate: (input) => {
if (input.length === 0) {
return 'Event name is required';
}
if (!/^(?:[a-zA-Z]+|\*)\.(created|updated|deleted|\*)$/.test(input)) {
return 'Event name must be in format: (objectName|*).(created|updated|deleted|*)';
}
return true;
},
},
]);
return {
universalIdentifier: uuid,
type: 'databaseEvent',
eventName,
};
}
private async createCronTrigger() {
const uuid = randomUUID();
const { schedule } = await inquirer.prompt<{ schedule: string }>([
{
type: 'input',
name: 'schedule',
message: 'Enter the cron schedule (e.g., 0 9 * * * for daily at 9 AM):',
validate: (input) => {
if (input.length === 0) {
return 'Schedule is required';
}
const parts = input.trim().split(/\s+/);
if (parts.length < 5 || parts.length > 6) {
return 'Cron schedule must have 5 or 6 fields (e.g., 0 9 * * *)';
}
return true;
},
},
]);
return {
universalIdentifier: uuid,
type: 'cron',
schedule,
};
}
private async createRouteTrigger() {
const uuid = randomUUID();
const { path } = await inquirer.prompt<{ path: string }>([
{
type: 'input',
name: 'path',
message: 'Enter the route path (e.g., /webhook/company):',
validate: (input) => {
if (input.length === 0) {
return 'Path is required';
}
if (!input.startsWith('/')) {
return 'Path must start with /';
}
return true;
},
},
]);
const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([
{
type: 'list',
name: 'httpMethod',
message: 'Select the HTTP method:',
choices: Object.values(HTTPMethod),
default: HTTPMethod.GET,
},
]);
const { isAuthRequired } = await inquirer.prompt<{
isAuthRequired: boolean;
}>([
{
type: 'confirm',
name: 'isAuthRequired',
message: 'Is authentication required?',
default: true,
},
]);
return {
universalIdentifier: uuid,
type: 'route',
path,
httpMethod,
isAuthRequired,
};
}
}
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/app-manifest-loader';
import { loadManifest } from '../utils/load-manifest';
export class AppDeleteCommand {
private apiService = new ApiService();
@@ -25,9 +25,11 @@ export class AppDeleteCommand {
process.exit(1);
}
const { packageJson } = await loadManifest(appPath);
const { manifest } = await loadManifest(appPath);
const result = await this.apiService.deleteApplication(packageJson);
const result = await this.apiService.deleteApplication(
manifest.application.universalIdentifier,
);
if (!result.success) {
console.error(chalk.red('❌ Deletion failed:'), result.error);
@@ -2,14 +2,17 @@ import chalk from 'chalk';
import * as chokidar from 'chokidar';
import { ApiService } from '../services/api.service';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { loadManifest } from '../utils/app-manifest-loader';
import { loadManifest } from '../utils/load-manifest';
export class AppDevCommand {
private apiService = new ApiService();
async execute(options: { debounce: string }): Promise<void> {
async execute(options: {
appPath?: string;
debounce: string;
}): Promise<void> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const debounceMs = parseInt(options.debounce, 10);
@@ -8,7 +8,7 @@ import kebabCase from 'lodash.kebabcase';
export class AppInitCommand {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDirectory, appDescription } =
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
await this.validateDirectory(appDirectory);
@@ -19,6 +19,7 @@ export class AppInitCommand {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
@@ -35,19 +36,31 @@ export class AppInitCommand {
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDirectory: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}> {
const { name, description } = await inquirer.prompt([
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name (eg: My awesome application):',
message: 'Application name (eg: my-awesome-app):',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
},
},
{
type: 'input',
name: 'displayName',
message: 'Display name (eg: My awesome app):',
default: (answers: any) => {
return answers.name
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
},
},
{
type: 'input',
name: 'description',
@@ -58,13 +71,15 @@ export class AppInitCommand {
const appName = name.trim();
const appDisplayName = displayName.trim();
const appDescription = description.trim();
const appDirectory = directory
? path.join(process.cwd(), kebabCase(directory))
: path.join(process.cwd(), kebabCase(appName));
return { appName, appDirectory, appDescription };
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
@@ -2,7 +2,7 @@ import chalk from 'chalk';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/app-manifest-loader';
import { loadManifest } from '../utils/load-manifest';
export class AppSyncCommand {
private apiService = new ApiService();
@@ -9,6 +9,7 @@ import { AppDeleteCommand } from './app-delete.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppSyncCommand } from './app-sync.command';
import { formatPath } from '../utils/format-path';
export class AppCommand {
private devCommand = new AppDevCommand();
@@ -22,19 +23,22 @@ export class AppCommand {
appCommand.description('Application development commands');
appCommand
.command('dev')
.command('dev [appPath]')
.description('Watch and sync local application changes')
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
.action(async (options) => {
await this.devCommand.execute(options);
.action(async (appPath, options) => {
await this.devCommand.execute({
...options,
appPath: formatPath(appPath),
});
});
appCommand
.command('sync')
.command('sync [appPath]')
.description('Sync application to Twenty')
.action(async () => {
.action(async (appPath?: string) => {
try {
const result = await this.syncCommand.execute();
const result = await this.syncCommand.execute(formatPath(appPath));
if (!result.success) {
process.exit(1);
}
@@ -44,11 +48,12 @@ export class AppCommand {
});
appCommand
.command('delete')
.command('delete [appPath]')
.description('Delete application from Twenty')
.action(async () => {
.action(async (appPath?: string) => {
try {
const result = await this.deleteCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: true,
});
if (!result.success) {