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:
+5
-2
@@ -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);
|
||||
}
|
||||
|
||||
+8
-4
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user