Add sdk build (#17335)

## Summary

Adds `app:build` command as a one-shot version of `app:dev` - builds
manifest, functions, and front components once then exits (no watching).

**Changes:**
- Added `watch` option to `FunctionsWatcher` and
`FrontComponentsWatcher` to support both watch and one-shot modes
- Created `AppBuildCommand` reusing the same build logic as `app:dev`
with `watch: false`
- Added integration tests for `app:build` on both `rich-app` and
`root-app`
- Updated manifest types: `handlerPath` → `sourceHandlerPath` +
`builtHandlerPath`, `componentPath` → `sourceComponentPath` +
`builtComponentPath`
- Updated test app `package.json` scripts to match `create-twenty-app`
template
This commit is contained in:
Charles Bochet
2026-01-22 14:36:40 +01:00
committed by GitHub
parent d6f088f720
commit d537d941a7
25 changed files with 1050 additions and 86 deletions
@@ -198,6 +198,7 @@ const createPackageJson = async ({
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'app:build': 'twenty app:build',
'app:sync': 'twenty app:sync',
'entity:add': 'twenty entity:add',
'app:generate': 'twenty app:generate',
@@ -4,7 +4,7 @@ export type FrontComponentType = React.ComponentType<any>;
export type FrontComponentConfig = Omit<
FrontComponentManifest,
'componentPath' | 'componentName'
'sourceComponentPath' | 'builtComponentPath' | 'componentName'
> & {
name?: string;
description?: string;
@@ -7,7 +7,7 @@ export type FunctionHandler = (...args: any[]) => any | Promise<any>;
export type FunctionConfig = Omit<
ServerlessFunctionManifest,
'handlerPath' | 'handlerName'
'sourceHandlerPath' | 'builtHandlerPath' | 'handlerName'
> & {
name?: string;
description?: string;
@@ -1,6 +1,6 @@
{
"name": "invalid-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -9,13 +9,32 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty app dev",
"sync": "twenty app sync"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,25 @@
import { runAppBuild } from '@/cli/__tests__/integration/utils/run-app-build.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { join } from 'path';
import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests';
import { defineFunctionsTests } from '../app-dev/tests/functions.tests';
import { defineManifestTests } from '../app-dev/tests/manifest.tests';
import { defineConsoleOutputTests } from './tests/console-output.tests';
const APP_PATH = join(__dirname, '../..');
describe('rich-app app:build', () => {
let result: RunCliCommandResult;
beforeAll(async () => {
result = await runAppBuild({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineFunctionsTests(APP_PATH);
defineFrontComponentsTests(APP_PATH);
});
@@ -0,0 +1,49 @@
import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = getOutputByPrefix(getResult().output, 'init');
expect(output).toContain('[init] 🚀 Building Twenty Application');
expect(output).toContain('[init] 📁 App Path:');
expect(output).toContain('[init] ✅ Build completed successfully');
});
it('should contain manifest-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'manifest-watch');
expect(output).toContain('[manifest-watch] 🔄 Building...');
expect(output).toContain('[manifest-watch] ✓ Loaded "Hello World"');
expect(output).toContain('[manifest-watch] ✓ Found 2 object(s)');
expect(output).toContain('[manifest-watch] ✓ Found 4 function(s)');
expect(output).toContain('[manifest-watch] ✓ Found 4 front component(s)');
expect(output).toContain('[manifest-watch] ✓ Found 2 role(s)');
expect(output).toContain('[manifest-watch] ✓ Written to');
});
it('should contain functions-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'functions-watch');
expect(output).toContain('[functions-watch] 📦 Building...');
expect(output).toContain('[functions-watch] ✓ Built');
});
it('should contain front-components-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'front-components-watch');
expect(output).toContain('[front-components-watch] 🎨 Building...');
expect(output).toContain('[front-components-watch] ✓ Built');
});
it('should not contain watching messages', () => {
const output = getResult().output;
expect(output).not.toContain('👀 Watching for changes...');
expect(output).not.toContain('📂 Watcher started');
});
});
};
@@ -16,31 +16,35 @@
},
"frontComponents": [
{
"builtComponentPath": "front-components/src/root.front-component.mjs",
"componentName": "RootComponent",
"componentPath": "src/root.front-component.tsx",
"description": "A root-level front component",
"name": "root-component",
"sourceComponentPath": "src/root.front-component.tsx",
"universalIdentifier": "a0a1a2a3-a4a5-4000-8000-000000000001"
},
{
"builtComponentPath": "front-components/src/components/card.front-component.mjs",
"componentName": "CardDisplay",
"componentPath": "src/components/card.front-component.tsx",
"description": "A component using an external component file",
"name": "card-component",
"sourceComponentPath": "src/components/card.front-component.tsx",
"universalIdentifier": "i0i1i2i3-i4i5-4000-8000-000000000001"
},
{
"builtComponentPath": "front-components/src/components/greeting.front-component.mjs",
"componentName": "GreetingComponent",
"componentPath": "src/components/greeting.front-component.tsx",
"description": "A component that uses greeting utility",
"name": "greeting-component",
"sourceComponentPath": "src/components/greeting.front-component.tsx",
"universalIdentifier": "h0h1h2h3-h4h5-4000-8000-000000000001"
},
{
"builtComponentPath": "front-components/src/components/test.front-component.mjs",
"componentName": "TestComponent",
"componentPath": "src/components/test.front-component.tsx",
"description": "A test front component",
"name": "test-component",
"sourceComponentPath": "src/components/test.front-component.tsx",
"universalIdentifier": "f1234567-abcd-4000-8000-000000000001"
}
],
@@ -190,7 +194,7 @@
],
"packageJson": {
"name": "rich-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -199,18 +203,33 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"create-entity": "twenty app add",
"dev": "twenty app dev",
"generate": "twenty app generate",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
},
"roles": [
@@ -263,9 +282,10 @@
],
"serverlessFunctions": [
{
"builtHandlerPath": "functions/src/root.function.mjs",
"handlerName": "rootHandler",
"handlerPath": "src/root.function.ts",
"name": "root-function",
"sourceHandlerPath": "src/root.function.ts",
"timeoutSeconds": 5,
"triggers": [
{
@@ -279,9 +299,10 @@
"universalIdentifier": "f0f1f2f3-f4f5-4000-8000-000000000001"
},
{
"builtHandlerPath": "functions/src/functions/greeting.function.mjs",
"handlerName": "greetingHandler",
"handlerPath": "src/functions/greeting.function.ts",
"name": "greeting-function",
"sourceHandlerPath": "src/functions/greeting.function.ts",
"timeoutSeconds": 5,
"triggers": [
{
@@ -295,9 +316,10 @@
"universalIdentifier": "g0g1g2g3-g4g5-4000-8000-000000000001"
},
{
"builtHandlerPath": "functions/src/utils/test-function-2.util.mjs",
"handlerName": "testFunction2",
"handlerPath": "src/utils/test-function-2.util.ts",
"name": "test-function-2",
"sourceHandlerPath": "src/utils/test-function-2.util.ts",
"timeoutSeconds": 2,
"triggers": [
{
@@ -309,9 +331,10 @@
"universalIdentifier": "eb3ffc98-88ec-45d4-9b4a-56833b219ccb"
},
{
"builtHandlerPath": "functions/src/functions/test-function.function.mjs",
"handlerName": "handler",
"handlerPath": "src/functions/test-function.function.ts",
"name": "test-function",
"sourceHandlerPath": "src/functions/test-function.function.ts",
"timeoutSeconds": 2,
"triggers": [
{
@@ -1,6 +1,6 @@
{
"name": "rich-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -9,17 +9,32 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"create-entity": "twenty app add",
"dev": "twenty app dev",
"generate": "twenty app generate",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,25 @@
import { join } from 'path';
import { runAppBuild } from '../../../../integration/utils/run-app-build.util';
import { type RunCliCommandResult } from '../../../../integration/utils/run-cli-command.util';
import { defineConsoleOutputTests } from './tests/console-output.tests';
import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests';
import { defineFunctionsTests } from '../app-dev/tests/functions.tests';
import { defineManifestTests } from '../app-dev/tests/manifest.tests';
const APP_PATH = join(__dirname, '../..');
describe('root-app app:build', () => {
let result: RunCliCommandResult;
beforeAll(async () => {
result = await runAppBuild({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineFunctionsTests(APP_PATH);
defineFrontComponentsTests(APP_PATH);
});
@@ -0,0 +1,49 @@
import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = getOutputByPrefix(getResult().output, 'init');
expect(output).toContain('[init] 🚀 Building Twenty Application');
expect(output).toContain('[init] 📁 App Path:');
expect(output).toContain('[init] ✅ Build completed successfully');
});
it('should contain manifest-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'manifest-watch');
expect(output).toContain('[manifest-watch] 🔄 Building...');
expect(output).toContain('[manifest-watch] ✓ Loaded "Root App"');
expect(output).toContain('[manifest-watch] ✓ Found 1 object(s)');
expect(output).toContain('[manifest-watch] ✓ Found 1 function(s)');
expect(output).toContain('[manifest-watch] ✓ Found 1 front component(s)');
expect(output).toContain('[manifest-watch] ✓ Found 1 role(s)');
expect(output).toContain('[manifest-watch] ✓ Written to');
});
it('should contain functions-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'functions-watch');
expect(output).toContain('[functions-watch] 📦 Building...');
expect(output).toContain('[functions-watch] ✓ Built');
});
it('should contain front-components-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'front-components-watch');
expect(output).toContain('[front-components-watch] 🎨 Building...');
expect(output).toContain('[front-components-watch] ✓ Built');
});
it('should not contain watching messages', () => {
const output = getResult().output;
expect(output).not.toContain('👀 Watching for changes...');
expect(output).not.toContain('📂 Watcher started');
});
});
};
@@ -40,7 +40,8 @@
}
],
"handlerName": "myHandler",
"handlerPath": "my.function.ts"
"sourceHandlerPath": "my.function.ts",
"builtHandlerPath": "functions/my.function.mjs"
}
],
"frontComponents": [
@@ -49,7 +50,8 @@
"name": "my-component",
"description": "A root-level front component",
"componentName": "MyComponent",
"componentPath": "my.front-component.tsx"
"sourceComponentPath": "my.front-component.tsx",
"builtComponentPath": "front-components/my.front-component.mjs"
}
],
"roles": [
@@ -1,6 +1,6 @@
{
"name": "root-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -9,17 +9,32 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"create-entity": "twenty app add",
"dev": "twenty app dev",
"generate": "twenty app generate",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,17 @@
import { runCliCommand, type RunCliCommandResult } from './run-cli-command.util';
export type RunAppBuildOptions = {
appPath: string;
timeout?: number;
};
export const runAppBuild = (options: RunAppBuildOptions): Promise<RunCliCommandResult> => {
const { appPath, timeout = 30000 } = options;
// app:build runs once and exits, so we don't wait for specific output
return runCliCommand({
command: 'app:build',
args: [appPath],
timeout,
});
};
@@ -54,15 +54,21 @@ export const runCliCommand = (
? [waitForOutput]
: [];
let isResolved = false;
child.stdout?.on('data', (data: Buffer) => {
output += data.toString();
if (
!isResolved &&
waitForOutputs.length > 0 &&
waitForOutputs.every((w) => output.includes(w))
) {
isResolved = true;
clearTimeout(timeoutId);
child.kill();
resolve({ success: true, output });
// Wait a bit before killing to allow any in-progress file writes to complete
setTimeout(() => {
child.kill();
resolve({ success: true, output });
}, 500);
}
});
@@ -94,9 +94,7 @@ export const registerCommands = (program: Command): void => {
...options,
appPath: formatPath(appPath),
});
if (!result.success) {
process.exit(1);
}
process.exit(result.success ? 0 : 1);
} catch {
process.exit(1);
}
@@ -1,28 +1,79 @@
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { createLogger } from '@/cli/utilities/build/common/logger';
import { FrontComponentsWatcher } from '@/cli/utilities/build/front-components/front-component-watcher';
import { FunctionsWatcher } from '@/cli/utilities/build/functions/function-watcher';
import { runManifestBuild, type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-build';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import chalk from 'chalk';
export type BuildCommandOptions = {
const initLogger = createLogger('init');
export type AppBuildOptions = {
appPath?: string;
};
export class AppBuildCommand {
async execute(options: BuildCommandOptions): Promise<ApiResponse<null>> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
private functionsBuilder: FunctionsWatcher | null = null;
private frontComponentsBuilder: FrontComponentsWatcher | null = null;
console.log(chalk.blue('🚀 Building Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
private appPath: string = '';
async execute(options: AppBuildOptions): Promise<ApiResponse<null>> {
this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
initLogger.log('🚀 Building Twenty Application');
initLogger.log(`📁 App Path: ${this.appPath}`);
console.log('');
const { manifest } = await runManifestBuild(appPath);
const buildResult = await this.runBuild();
if (!manifest) {
if (!buildResult) {
return { success: false, error: 'Build failed' };
}
console.log(chalk.green('✅ Build completed successfully'));
initLogger.success('✅ Build completed successfully');
return { success: true, data: null };
}
private async runBuild(): Promise<ManifestBuildResult | null> {
const buildResult = await runManifestBuild(this.appPath);
if (!buildResult.manifest) {
return null;
}
await this.buildFunctions(buildResult);
await this.buildFrontComponents(buildResult);
await this.cleanup();
return buildResult;
}
private async buildFunctions(buildResult: ManifestBuildResult): Promise<void> {
this.functionsBuilder = new FunctionsWatcher({
appPath: this.appPath,
buildResult,
watch: false,
});
await this.functionsBuilder.start();
}
private async buildFrontComponents(buildResult: ManifestBuildResult): Promise<void> {
this.frontComponentsBuilder = new FrontComponentsWatcher({
appPath: this.appPath,
buildResult,
watch: false,
});
await this.frontComponentsBuilder.start();
}
private async cleanup(): Promise<void> {
await this.functionsBuilder?.close();
await this.frontComponentsBuilder?.close();
await manifestExtractFromFileServer.closeViteServer();
}
}
@@ -10,4 +10,5 @@ export interface RestartableWatcher {
export type RestartableWatcherOptions = {
appPath: string;
buildResult: ManifestBuildResult | null;
watch?: boolean;
};
@@ -29,10 +29,13 @@ export class FrontComponentsWatcher implements RestartableWatcher {
private componentPaths: string[];
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private watchMode: boolean;
private lastInputsSignature: string | null = null;
constructor(options: RestartableWatcherOptions) {
this.appPath = options.appPath;
this.componentPaths = options.buildResult?.filePaths.frontComponents ?? [];
this.watchMode = options.watch ?? true;
}
shouldRestart(result: ManifestBuildResult): boolean {
@@ -51,7 +54,9 @@ export class FrontComponentsWatcher implements RestartableWatcher {
await this.createContext();
} else {
logger.log('No front components to build');
logger.log('👀 Watching for changes...');
if (this.watchMode) {
logger.log('👀 Watching for changes...');
}
}
}
@@ -96,6 +101,11 @@ export class FrontComponentsWatcher implements RestartableWatcher {
entryPoints[entryName] = path.join(this.appPath, componentPath);
}
const watchMode = this.watchMode;
// Capture reference for use in plugin callbacks
const watcher = this;
this.esBuildContext = await esbuild.context({
entryPoints,
bundle: true,
@@ -119,14 +129,25 @@ export class FrontComponentsWatcher implements RestartableWatcher {
for (const error of result.errors) {
logger.error(` ${error.text}`);
}
} else {
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
return;
}
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
const inputs = Object.keys(result.metafile?.inputs ?? {}).sort();
const inputsSignature = inputs.join(',');
if (watcher.lastInputsSignature === inputsSignature) {
return;
}
watcher.lastInputsSignature = inputsSignature;
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
if (watchMode) {
logger.log('👀 Watching for changes...');
}
});
@@ -137,6 +158,8 @@ export class FrontComponentsWatcher implements RestartableWatcher {
await this.esBuildContext.rebuild();
await this.esBuildContext.watch();
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
@@ -43,10 +43,13 @@ export class FunctionsWatcher implements RestartableWatcher {
private functionPaths: string[];
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private watchMode: boolean;
private lastInputsSignature: string | null = null;
constructor(options: RestartableWatcherOptions) {
this.appPath = options.appPath;
this.functionPaths = options.buildResult?.filePaths.functions ?? [];
this.watchMode = options.watch ?? true;
}
shouldRestart(result: ManifestBuildResult): boolean {
@@ -65,7 +68,9 @@ export class FunctionsWatcher implements RestartableWatcher {
await this.createContext();
} else {
logger.log('No functions to build');
logger.log('👀 Watching for changes...');
if (this.watchMode) {
logger.log('👀 Watching for changes...');
}
}
}
@@ -110,6 +115,11 @@ export class FunctionsWatcher implements RestartableWatcher {
entryPoints[entryName] = path.join(this.appPath, functionPath);
}
const watchMode = this.watchMode;
// Capture reference for use in plugin callbacks
const watcher = this;
this.esBuildContext = await esbuild.context({
entryPoints,
bundle: true,
@@ -143,14 +153,25 @@ export class FunctionsWatcher implements RestartableWatcher {
for (const error of result.errors) {
logger.error(` ${error.text}`);
}
} else {
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
return;
}
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
const inputs = Object.keys(result.metafile?.inputs ?? {}).sort();
const inputsSignature = inputs.join(',');
if (watcher.lastInputsSignature === inputsSignature) {
return;
}
watcher.lastInputsSignature = inputsSignature;
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
if (watchMode) {
logger.log('👀 Watching for changes...');
}
});
@@ -161,6 +182,8 @@ export class FunctionsWatcher implements RestartableWatcher {
await this.esBuildContext.rebuild();
await this.esBuildContext.watch();
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
@@ -1,6 +1,7 @@
import { glob } from 'fast-glob';
import { type FrontComponentManifest } from 'twenty-shared/application';
import { createLogger } from '../../common/logger';
import { FRONT_COMPONENTS_DIR } from '../../front-components/constants';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import { type ValidationError } from '../manifest.types';
import {
@@ -12,7 +13,10 @@ import {
const logger = createLogger('manifest-watch');
type FrontComponentConfig = Omit<FrontComponentManifest, 'componentPath' | 'componentName'> & {
type FrontComponentConfig = Omit<
FrontComponentManifest,
'sourceComponentPath' | 'builtComponentPath' | 'componentName'
> & {
component: { name: string };
};
@@ -36,11 +40,13 @@ export class FrontComponentEntityBuilder
);
const { component, ...rest } = config;
const builtComponentPath = this.computeBuiltComponentPath(filePath);
manifests.push({
...rest,
componentName: component.name,
componentPath: filePath,
sourceComponentPath: filePath,
builtComponentPath,
});
} catch (error) {
throw new Error(
@@ -52,6 +58,12 @@ export class FrontComponentEntityBuilder
return { manifests, filePaths: componentFiles };
}
private computeBuiltComponentPath(sourceComponentPath: string): string {
const builtPath = sourceComponentPath.replace(/\.tsx?$/, '.mjs');
return `${FRONT_COMPONENTS_DIR}/${builtPath}`;
}
validate(
components: FrontComponentManifest[],
errors: ValidationError[],
@@ -75,7 +87,7 @@ export class FrontComponentEntityBuilder
logger.log('📍 Entry points:');
for (const component of components) {
const name = component.name || component.universalIdentifier;
logger.log(` - ${name} (${component.componentPath})`);
logger.log(` - ${name} (${component.sourceComponentPath})`);
}
}
}
@@ -1,6 +1,7 @@
import { glob } from 'fast-glob';
import { type ServerlessFunctionManifest } from 'twenty-shared/application';
import { createLogger } from '../../common/logger';
import { FUNCTIONS_DIR } from '../../functions/constants';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import { type ValidationError } from '../manifest.types';
import {
@@ -12,6 +13,13 @@ import {
const logger = createLogger('manifest-watch');
type ExtractedFunctionManifest = Omit<
ServerlessFunctionManifest,
'sourceHandlerPath' | 'builtHandlerPath'
> & {
handlerPath: string;
};
export class FunctionEntityBuilder
implements ManifestEntityBuilder<ServerlessFunctionManifest>
{
@@ -27,12 +35,20 @@ export class FunctionEntityBuilder
try {
const absolutePath = `${appPath}/${filePath}`;
manifests.push(
await manifestExtractFromFileServer.extractManifestFromFile<ServerlessFunctionManifest>(
const extracted =
await manifestExtractFromFileServer.extractManifestFromFile<ExtractedFunctionManifest>(
absolutePath,
{ entryProperty: 'handler' },
),
);
);
const { handlerPath, ...rest } = extracted;
const builtHandlerPath = this.computeBuiltHandlerPath(handlerPath);
manifests.push({
...rest,
sourceHandlerPath: handlerPath,
builtHandlerPath,
});
} catch (error) {
throw new Error(
`Failed to load function from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
@@ -43,6 +59,12 @@ export class FunctionEntityBuilder
return { manifests, filePaths: functionFiles };
}
private computeBuiltHandlerPath(sourceHandlerPath: string): string {
const builtPath = sourceHandlerPath.replace(/\.tsx?$/, '.mjs');
return `${FUNCTIONS_DIR}/${builtPath}`;
}
validate(
functions: ServerlessFunctionManifest[],
errors: ValidationError[],
@@ -120,7 +142,7 @@ export class FunctionEntityBuilder
logger.log('📍 Entry points:');
for (const fn of functions) {
const name = fn.name || fn.universalIdentifier;
logger.log(` - ${name} (${fn.handlerPath})`);
logger.log(` - ${name} (${fn.sourceHandlerPath})`);
}
}
}
@@ -947,7 +947,7 @@ export class ApplicationSyncService {
name,
code,
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
handlerPath: serverlessFunctionToSync.handlerPath,
handlerPath: serverlessFunctionToSync.sourceHandlerPath,
handlerName: serverlessFunctionToSync.handlerName,
toolInputSchema: serverlessFunctionToSync.toolInputSchema,
isTool: serverlessFunctionToSync.isTool,
@@ -991,7 +991,7 @@ export class ApplicationSyncService {
code,
universalIdentifier: serverlessFunctionToCreate.universalIdentifier,
timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds,
handlerPath: serverlessFunctionToCreate.handlerPath,
handlerPath: serverlessFunctionToCreate.sourceHandlerPath,
handlerName: serverlessFunctionToCreate.handlerName,
applicationId,
serverlessFunctionLayerId,
@@ -2,6 +2,7 @@ export type FrontComponentManifest = {
universalIdentifier: string;
name?: string;
description?: string;
componentPath: string;
sourceComponentPath: string;
builtComponentPath: string;
componentName: string;
};
@@ -24,8 +24,8 @@ export type ServerlessFunctionManifest = SyncableEntityOptions & {
description?: string;
timeoutSeconds?: number;
triggers: ServerlessFunctionTriggerManifest[];
handlerPath: string;
builtHandlerPath?: string; // Should be required when build mode implemented
sourceHandlerPath: string;
builtHandlerPath: string;
handlerName: string;
toolInputSchema?: InputJsonSchema;
isTool?: boolean;