Twenty SDK iteration (#17223)

Opening a PR to merge the wip work by @martmull 
We will re-organize twenty-sdk in an upcoming PR

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Charles Bochet 2026-01-19 09:41:54 +01:00 committed by GitHub
parent b001991047
commit 5cef07af45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1547 additions and 16 deletions

View file

@ -75,6 +75,7 @@ export default defineConfig(() => {
'path',
'fs',
'child_process',
'util',
],
output: [
{

View file

@ -32,6 +32,7 @@
"dependencies": {
"@genql/cli": "^3.0.3",
"@sniptt/guards": "^0.2.0",
"archiver": "^7.0.1",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
@ -49,9 +50,12 @@
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"typescript": "^5.9.2",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^4.2.1"
},
"devDependencies": {
"@types/archiver": "^6.0.0",
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.5.0",
@ -62,9 +66,7 @@
"@types/node": "^24.0.0",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1",
"wait-on": "^7.2.0"
},
"engines": {

View file

@ -0,0 +1,67 @@
import * as fs from 'fs-extra';
import path from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
import { type BuiltFunctionInfo } from './types';
/**
* BuildManifestWriter creates the output manifest.json for a built application.
*
* The output manifest differs from the source manifest:
* - `serverlessFunctions[].handlerPath` points to built .js files
* - `sources` field is removed (replaced by built files)
*/
export class BuildManifestWriter {
/**
* Write the built manifest to the output directory.
*
* @param manifest - The original application manifest
* @param builtFunctions - Information about built functions with new paths
* @param outputDir - The output directory path
* @returns The path to the written manifest file
*/
async write(params: {
manifest: ApplicationManifest;
builtFunctions: BuiltFunctionInfo[];
outputDir: string;
}): Promise<string> {
const { manifest, builtFunctions, outputDir } = params;
// Create a map of universalIdentifier -> built handler path
const builtPathMap = new Map<string, string>();
for (const fn of builtFunctions) {
builtPathMap.set(fn.universalIdentifier, fn.builtHandlerPath);
}
// Create the output manifest with updated handler paths
const outputManifest: Omit<ApplicationManifest, 'sources'> = {
application: manifest.application,
objects: manifest.objects,
objectExtensions: manifest.objectExtensions,
serverlessFunctions: manifest.serverlessFunctions.map((fn) => {
const builtPath = builtPathMap.get(fn.universalIdentifier);
if (!builtPath) {
// If function wasn't built, keep original path (shouldn't happen normally)
return fn;
}
return {
...fn,
// Update handler path to point to the built .js file
handlerPath: builtPath,
};
}),
roles: manifest.roles,
};
const manifestPath = path.join(outputDir, 'manifest.json');
// Ensure the output directory exists
await fs.ensureDir(outputDir);
// Write the manifest with pretty formatting
await fs.writeJSON(manifestPath, outputManifest, { spaces: 2 });
return manifestPath;
}
}

View file

@ -0,0 +1,238 @@
import * as chokidar from 'chokidar';
import path from 'path';
import { type BuildWatcherState, type RebuildDecision } from './types';
/**
* BuildWatcher monitors file changes and triggers rebuilds.
*
* State machine:
* - IDLE: Waiting for changes
* - ANALYZING: Determining which files changed and what to rebuild
* - BUILDING: Rebuild in progress
* - ERROR: Build failed (can recover)
* - SUCCESS: Build succeeded, returning to IDLE
*/
export class BuildWatcher {
private state: BuildWatcherState = 'IDLE';
private watcher: chokidar.FSWatcher | null = null;
private pendingChanges: Set<string> = new Set();
private debounceTimer: NodeJS.Timeout | null = null;
private readonly debounceMs: number;
constructor(
private readonly appPath: string,
debounceMs = 500,
) {
this.debounceMs = debounceMs;
}
/**
* Start watching for file changes.
*/
async start(
onRebuild: (decision: RebuildDecision) => Promise<void>,
): Promise<void> {
this.watcher = chokidar.watch(this.appPath, {
ignored: [
/node_modules/,
/\.git/,
/\.twenty\/output/,
/\.twenty\/.*\.tar\.gz$/,
/dist/,
],
persistent: true,
ignoreInitial: true,
});
const handleChange = (filepath: string) => {
// Only watch TypeScript files and relevant config files
if (!this.isWatchedFile(filepath)) {
return;
}
this.pendingChanges.add(filepath);
// Debounce rapid changes
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(async () => {
const changedFiles = Array.from(this.pendingChanges);
this.pendingChanges.clear();
if (changedFiles.length === 0) {
return;
}
this.state = 'ANALYZING';
const decision = this.analyzeChanges(changedFiles);
this.state = 'BUILDING';
try {
await onRebuild(decision);
this.state = 'SUCCESS';
} catch {
this.state = 'ERROR';
} finally {
this.state = 'IDLE';
}
}, this.debounceMs);
};
this.watcher.on('change', handleChange);
this.watcher.on('add', handleChange);
this.watcher.on('unlink', handleChange);
}
/**
* Stop watching for file changes.
*/
async stop(): Promise<void> {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
this.state = 'IDLE';
}
/**
* Check if a file should trigger a rebuild.
*/
private isWatchedFile(filepath: string): boolean {
const ext = path.extname(filepath);
const basename = path.basename(filepath);
const relativePath = path.relative(this.appPath, filepath);
// Watch TypeScript files
if (ext === '.ts' || ext === '.tsx') {
return true;
}
// Watch relevant config files
if (
basename === 'package.json' ||
basename === 'tsconfig.json' ||
basename === '.env'
) {
return true;
}
// Watch asset files in src/assets/
if (relativePath.startsWith('src/assets/') || relativePath.startsWith('src\\assets\\')) {
return true;
}
return false;
}
/**
* Analyze changed files to determine what needs to be rebuilt.
*
* Manifest is composed from:
* - src/app/application.config.ts
* - src/app/**\/*.object.ts
* - src/app/**\/*.object-extension.ts
* - src/app/**\/*.role.ts
* - src/app/**\/*.function.ts (also requires function rebuild)
*/
private analyzeChanges(changedFiles: string[]): RebuildDecision {
const affectedFunctions: string[] = [];
let rebuildGenerated = false;
let assetsChanged = false;
let sharedFilesChanged = false;
let configChanged = false;
let manifestChanged = false;
for (const filepath of changedFiles) {
const relativePath = path.relative(this.appPath, filepath);
// Normalize path separators for cross-platform compatibility
const normalizedPath = relativePath.replace(/\\/g, '/');
const basename = path.basename(normalizedPath);
// Check if it's a build config file (requires full rebuild)
if (
basename === 'package.json' ||
basename === 'tsconfig.json' ||
basename === '.env'
) {
configChanged = true;
continue;
}
// Check if it's an asset file
if (normalizedPath.startsWith('src/assets/')) {
assetsChanged = true;
continue;
}
// Check if it's in the generated folder
if (normalizedPath.startsWith('generated/')) {
rebuildGenerated = true;
continue;
}
// Check if it's a manifest-related file in src/app/
if (normalizedPath.startsWith('src/app/')) {
// Function files: rebuild function AND regenerate manifest
if (normalizedPath.endsWith('.function.ts')) {
affectedFunctions.push(normalizedPath);
manifestChanged = true;
continue;
}
// Other manifest files: only regenerate manifest (no function rebuild)
// - application.config.ts
// - *.object.ts
// - *.object-extension.ts
// - *.role.ts
if (
basename === 'application.config.ts' ||
normalizedPath.endsWith('.object.ts') ||
normalizedPath.endsWith('.object-extension.ts') ||
normalizedPath.endsWith('.role.ts')
) {
manifestChanged = true;
continue;
}
}
// Check if it's a shared file that affects all functions
if (
normalizedPath.startsWith('src/') &&
!normalizedPath.startsWith('src/assets/') &&
!normalizedPath.startsWith('src/app/') &&
(normalizedPath.endsWith('.ts') || normalizedPath.endsWith('.tsx'))
) {
// Shared utility file outside src/app/ - rebuild all functions
sharedFilesChanged = true;
}
}
return {
shouldRebuild: true,
affectedFunctions,
sharedFilesChanged,
configChanged,
manifestChanged,
rebuildGenerated,
assetsChanged,
changedFiles,
};
}
/**
* Get the current watcher state.
*/
getState(): BuildWatcherState {
return this.state;
}
}

View file

@ -0,0 +1,663 @@
import path from 'path';
import * as fs from 'fs-extra';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import { type ApiResponse } from '@/cli/types/api-response.types';
import {
loadManifest,
type LoadManifestResult,
} from '@/cli/utils/load-manifest';
import { ViteBuildRunner } from './vite-build-runner';
import { BuildManifestWriter } from './build-manifest-writer';
import { TarballService } from './tarball.service';
import { BuildWatcher } from './build-watcher';
import {
type BuildOptions,
type BuildResult,
type BuiltFunctionInfo,
type ViteBuildConfig,
type BuildWatchHandle,
type RebuildDecision,
} from './types';
/**
* BuildService orchestrates the build process for Twenty applications.
*
* Responsibilities:
* - Load the application manifest
* - Compute function entrypoints from serverlessFunctions[].handlerPath
* - Build each function using Vite
* - Build the generated/ folder if it exists
* - Copy assets
* - Write output manifest.json with updated paths
*/
export class BuildService {
private viteBuildRunner = new ViteBuildRunner();
private manifestWriter = new BuildManifestWriter();
private tarballService = new TarballService();
/** Cached state from the last successful build (used for incremental rebuilds) */
private lastBuildState: {
manifestResult: LoadManifestResult;
builtFunctions: BuiltFunctionInfo[];
outputDir: string;
} | null = null;
private readonly OUTPUT_DIR = '.twenty/output';
private readonly FUNCTIONS_DIR = 'functions';
private readonly GENERATED_DIR = 'generated';
private readonly ASSETS_DIR = 'assets';
/**
* Patterns to identify asset files that should be copied.
* Assets are static files needed at runtime but not TypeScript code.
*/
private readonly ASSET_PATTERNS = ['src/assets/**/*'];
/**
* Files/patterns to exclude from asset copying.
*/
private readonly ASSET_IGNORE = [
'**/node_modules/**',
'**/*.ts',
'**/*.tsx',
'**/.DS_Store',
'**/tsconfig.json',
'**/package.json',
'**/yarn.lock',
'**/.git/**',
];
/**
* Perform a one-time build of the application.
*/
async build(options: BuildOptions): Promise<ApiResponse<BuildResult>> {
const { appPath, tarball } = options;
try {
console.log(chalk.blue('📦 Building Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
// Step 1: Load manifest
console.log(chalk.gray(' Loading manifest...'));
const manifestResult = await loadManifest(appPath);
// Step 2: Prepare output directory
const outputDir = path.join(appPath, this.OUTPUT_DIR);
await this.prepareOutputDirectory(outputDir);
// Step 3: Build all functions
console.log(
chalk.gray(
` Building ${manifestResult.manifest.serverlessFunctions.length} function(s)...`,
),
);
const builtFunctions = await this.buildFunctions(
appPath,
outputDir,
manifestResult,
);
// Check for build failures
const failures = builtFunctions.filter(
(fn) => fn.builtHandlerPath === '',
);
if (failures.length > 0) {
return {
success: false,
error: `Failed to build ${failures.length} function(s)`,
};
}
// Step 4: Build generated folder if it exists
const generatedPath = path.join(appPath, 'generated');
if (await fs.pathExists(generatedPath)) {
console.log(chalk.gray(' Building generated client...'));
await this.buildGeneratedFolder(appPath, outputDir);
}
// Step 5: Copy assets
const assetsCopied = await this.copyAssets(appPath, outputDir);
if (assetsCopied > 0) {
console.log(chalk.gray(` Copied ${assetsCopied} asset(s)`));
}
// Step 6: Write output manifest
console.log(chalk.gray(' Writing manifest...'));
await this.manifestWriter.write({
manifest: manifestResult.manifest,
builtFunctions,
outputDir,
});
// Step 7: Create tarball if requested
let tarballPath: string | undefined;
if (tarball) {
console.log(chalk.gray(' Creating tarball...'));
tarballPath = await this.tarballService.create({
sourceDir: outputDir,
outputPath: path.join(
appPath,
'.twenty',
`${manifestResult.manifest.application.displayName || 'app'}.tar.gz`,
),
});
}
const buildResult: BuildResult = {
outputDir,
manifest: manifestResult.manifest,
builtFunctions,
tarballPath,
};
// Cache the build state for incremental rebuilds
this.lastBuildState = {
manifestResult,
builtFunctions,
outputDir,
};
console.log('');
console.log(chalk.green('✅ Build completed successfully'));
console.log(chalk.gray(` Output: ${outputDir}`));
if (tarballPath) {
console.log(chalk.gray(` Tarball: ${tarballPath}`));
}
return { success: true, data: buildResult };
} catch (error) {
console.error(
chalk.red('❌ Build failed:'),
error instanceof Error ? error.message : error,
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Start watch mode for incremental rebuilds.
*/
async watch(options: BuildOptions): Promise<BuildWatchHandle> {
const { appPath } = options;
console.log(chalk.blue('📦 Starting build watch mode'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
// Perform initial build
const initialResult = await this.build({ ...options, tarball: false });
if (!initialResult.success) {
console.error(
chalk.red('Initial build failed, starting watcher anyway...'),
);
}
// Start the watcher
const watcher = new BuildWatcher(appPath);
await watcher.start(async (decision) => {
if (!decision.shouldRebuild) {
return;
}
// Use incremental rebuild based on what changed
const result = await this.incrementalRebuild(appPath, decision);
if (result.success) {
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
}
});
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
return {
stop: () => watcher.stop(),
};
}
/**
* Perform an incremental rebuild based on what files changed.
*
* This is more efficient than a full rebuild because it only rebuilds
* the parts of the application that were affected by the changes.
*/
private async incrementalRebuild(
appPath: string,
decision: RebuildDecision,
): Promise<ApiResponse<void>> {
try {
// If config changed or we don't have cached state, do a full rebuild
if (decision.configChanged || !this.lastBuildState) {
console.log(chalk.blue('🔄 Config changed, performing full rebuild...'));
const result = await this.build({ appPath, tarball: false });
if (result.success) {
return { success: true, data: undefined };
}
return { success: false, error: result.error };
}
let { manifestResult, outputDir } = this.lastBuildState;
let { builtFunctions } = this.lastBuildState;
let rebuildCount = 0;
// If manifest config changed, reload it
if (decision.manifestChanged) {
console.log(chalk.blue('🔄 Manifest changed, regenerating...'));
manifestResult = await loadManifest(appPath);
rebuildCount++;
}
// Determine which functions need rebuilding
const functionsToRebuild: string[] = [];
if (decision.sharedFilesChanged) {
// Shared files changed - rebuild ALL functions
console.log(
chalk.blue('🔄 Shared files changed, rebuilding all functions...'),
);
functionsToRebuild.push(
...manifestResult.manifest.serverlessFunctions.map(
(fn) => fn.handlerPath,
),
);
} else if (decision.affectedFunctions.length > 0) {
// Only specific functions changed
console.log(
chalk.blue(
`🔄 Rebuilding ${decision.affectedFunctions.length} function(s)...`,
),
);
functionsToRebuild.push(...decision.affectedFunctions);
}
// Rebuild affected functions
if (functionsToRebuild.length > 0) {
const rebuiltFunctions = await this.rebuildSpecificFunctions(
appPath,
outputDir,
manifestResult,
functionsToRebuild,
);
// Merge rebuilt functions into the existing list
builtFunctions = this.mergeBuiltFunctions(
builtFunctions,
rebuiltFunctions,
);
rebuildCount += rebuiltFunctions.length;
}
// Rebuild generated folder if needed
if (decision.rebuildGenerated) {
console.log(chalk.gray(' Rebuilding generated client...'));
await this.buildGeneratedFolder(appPath, outputDir);
rebuildCount++;
}
// Copy assets if needed
if (decision.assetsChanged) {
const assetsCopied = await this.copyAssets(appPath, outputDir);
if (assetsCopied > 0) {
console.log(chalk.gray(` Copied ${assetsCopied} asset(s)`));
rebuildCount++;
}
}
// Update manifest after any rebuild
if (rebuildCount > 0) {
await this.manifestWriter.write({
manifest: manifestResult.manifest,
builtFunctions,
outputDir,
});
// Update cached state
this.lastBuildState = {
manifestResult,
builtFunctions,
outputDir,
};
console.log(chalk.green('✅ Incremental rebuild completed'));
}
return { success: true, data: undefined };
} catch (error) {
console.error(
chalk.red('❌ Incremental rebuild failed:'),
error instanceof Error ? error.message : error,
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Rebuild only specific functions that changed.
*/
private async rebuildSpecificFunctions(
appPath: string,
outputDir: string,
manifestResult: LoadManifestResult,
handlerPaths: string[],
): Promise<BuiltFunctionInfo[]> {
const { manifest } = manifestResult;
const functionsOutputDir = path.join(outputDir, this.FUNCTIONS_DIR);
// Normalize paths for comparison
const normalizedPaths = new Set(
handlerPaths.map((p) => p.replace(/\\/g, '/')),
);
// Find the functions to rebuild
const functionsToRebuild = manifest.serverlessFunctions.filter((fn) => {
const normalizedHandler = fn.handlerPath.replace(/\\/g, '/');
return normalizedPaths.has(normalizedHandler);
});
if (functionsToRebuild.length === 0) {
return [];
}
// Build configs for the affected functions
const buildConfigs: ViteBuildConfig[] = functionsToRebuild.map((fn) => {
const { relativePath, outputDir: fnOutputDir } =
this.computeFunctionOutputPath(fn.handlerPath);
const outputFileName = path.basename(relativePath);
const depth = fnOutputDir ? fnOutputDir.split('/').length + 1 : 1;
const generatedRelativePath =
'../'.repeat(depth) + this.GENERATED_DIR + '/index.js';
return {
appPath,
entryPath: fn.handlerPath,
outputDir: path.join(functionsOutputDir, fnOutputDir),
outputFileName,
generatedRelativePath,
};
});
const results =
await this.viteBuildRunner.buildFunctionsParallel(buildConfigs);
const builtFunctions: BuiltFunctionInfo[] = [];
for (const fn of functionsToRebuild) {
const { relativePath } = this.computeFunctionOutputPath(fn.handlerPath);
const outputFileName = path.basename(relativePath);
const result = results.get(outputFileName);
if (result?.success) {
console.log(chalk.gray(`${fn.name || fn.universalIdentifier}`));
builtFunctions.push({
name: fn.name || fn.universalIdentifier,
universalIdentifier: fn.universalIdentifier,
originalHandlerPath: fn.handlerPath,
builtHandlerPath: `${this.FUNCTIONS_DIR}/${relativePath}`,
sourceMapPath: result.sourceMapPath
? `${this.FUNCTIONS_DIR}/${relativePath}.map`
: undefined,
});
} else {
console.error(
chalk.red(`${fn.name || fn.universalIdentifier}`),
result?.error?.message || 'Unknown error',
);
builtFunctions.push({
name: fn.name || fn.universalIdentifier,
universalIdentifier: fn.universalIdentifier,
originalHandlerPath: fn.handlerPath,
builtHandlerPath: '', // Empty indicates failure
});
}
}
return builtFunctions;
}
/**
* Merge newly rebuilt functions into the existing list.
*/
private mergeBuiltFunctions(
existing: BuiltFunctionInfo[],
rebuilt: BuiltFunctionInfo[],
): BuiltFunctionInfo[] {
const rebuiltMap = new Map(
rebuilt.map((fn) => [fn.universalIdentifier, fn]),
);
return existing.map((fn) => rebuiltMap.get(fn.universalIdentifier) || fn);
}
/**
* Prepare the output directory by cleaning and recreating it.
*/
private async prepareOutputDirectory(outputDir: string): Promise<void> {
await fs.remove(outputDir);
await fs.ensureDir(outputDir);
await fs.ensureDir(path.join(outputDir, this.FUNCTIONS_DIR));
}
/**
* Build all serverless functions from the manifest.
*/
private async buildFunctions(
appPath: string,
outputDir: string,
manifestResult: LoadManifestResult,
): Promise<BuiltFunctionInfo[]> {
const { manifest } = manifestResult;
const functionsOutputDir = path.join(outputDir, this.FUNCTIONS_DIR);
// Compute output paths preserving directory structure
const functionOutputPaths = manifest.serverlessFunctions.map((fn) =>
this.computeFunctionOutputPath(fn.handlerPath),
);
// Ensure all subdirectories exist
const uniqueDirs = new Set(
functionOutputPaths
.map((p) => path.dirname(p.relativePath))
.filter(Boolean),
);
for (const dir of uniqueDirs) {
await fs.ensureDir(path.join(functionsOutputDir, dir));
}
const buildConfigs: ViteBuildConfig[] = manifest.serverlessFunctions.map(
(fn, index) => {
const { relativePath, outputDir: fnOutputDir } =
functionOutputPaths[index];
const outputFileName = path.basename(relativePath);
// Compute the relative path from the function to the generated folder
// functions/lqq.function.js → ../generated/index.js
// functions/toto/lqq.function.js → ../../generated/index.js
const depth = fnOutputDir ? fnOutputDir.split('/').length + 1 : 1;
const generatedRelativePath =
'../'.repeat(depth) + this.GENERATED_DIR + '/index.js';
return {
appPath,
entryPath: fn.handlerPath,
outputDir: path.join(functionsOutputDir, fnOutputDir),
outputFileName,
generatedRelativePath,
};
},
);
const results =
await this.viteBuildRunner.buildFunctionsParallel(buildConfigs);
const builtFunctions: BuiltFunctionInfo[] = [];
for (let i = 0; i < manifest.serverlessFunctions.length; i++) {
const fn = manifest.serverlessFunctions[i];
const { relativePath } = functionOutputPaths[i];
const outputFileName = path.basename(relativePath);
const result = results.get(outputFileName);
if (result?.success) {
console.log(chalk.gray(`${fn.name || fn.universalIdentifier}`));
builtFunctions.push({
name: fn.name || fn.universalIdentifier,
universalIdentifier: fn.universalIdentifier,
originalHandlerPath: fn.handlerPath,
builtHandlerPath: `${this.FUNCTIONS_DIR}/${relativePath}`,
sourceMapPath: result.sourceMapPath
? `${this.FUNCTIONS_DIR}/${relativePath}.map`
: undefined,
});
} else {
console.error(
chalk.red(`${fn.name || fn.universalIdentifier}`),
result?.error?.message || 'Unknown error',
);
builtFunctions.push({
name: fn.name || fn.universalIdentifier,
universalIdentifier: fn.universalIdentifier,
originalHandlerPath: fn.handlerPath,
builtHandlerPath: '', // Empty indicates failure
});
}
}
return builtFunctions;
}
/**
* Compute the output path for a function, preserving directory structure.
*
* Examples:
* - src/app/lqq.function.ts { relativePath: 'lqq.function.js', outputDir: '' }
* - src/app/toto/lqq.function.ts { relativePath: 'toto/lqq.function.js', outputDir: 'toto' }
*/
private computeFunctionOutputPath(handlerPath: string): {
relativePath: string;
outputDir: string;
} {
// Normalize path separators
const normalizedPath = handlerPath.replace(/\\/g, '/');
// Remove src/app/ prefix if present
let relativePath = normalizedPath;
if (relativePath.startsWith('src/app/')) {
relativePath = relativePath.slice('src/app/'.length);
} else if (relativePath.startsWith('src/')) {
relativePath = relativePath.slice('src/'.length);
}
// Change extension from .ts to .js
relativePath = relativePath.replace(/\.ts$/, '.js');
// Get the directory part (empty string if no subdirectory)
const outputDir = path.dirname(relativePath);
const normalizedOutputDir = outputDir === '.' ? '' : outputDir;
return {
relativePath,
outputDir: normalizedOutputDir,
};
}
/**
* Build the generated/ folder containing the GraphQL client.
*/
private async buildGeneratedFolder(
appPath: string,
outputDir: string,
): Promise<void> {
const generatedIndexPath = path.join(appPath, 'generated', 'index.ts');
const generatedOutputDir = path.join(outputDir, this.GENERATED_DIR);
if (!(await fs.pathExists(generatedIndexPath))) {
// No index.ts in generated folder, skip
return;
}
await fs.ensureDir(generatedOutputDir);
const result = await this.viteBuildRunner.buildGenerated({
appPath,
entryPath: 'generated/index.ts',
outputDir: generatedOutputDir,
outputFileName: 'index.js',
});
if (result.success) {
console.log(chalk.gray(' ✓ generated/index.js'));
} else {
console.error(
chalk.red(' ✗ generated/index.js'),
result.error?.message || 'Unknown error',
);
}
}
/**
* Copy static assets from the app to the output directory.
*
* Looks for assets in:
* - src/assets/
* - assets/
*
* @returns The number of files copied
*/
private async copyAssets(
appPath: string,
outputDir: string,
): Promise<number> {
const assetsOutputDir = path.join(outputDir, this.ASSETS_DIR);
// Find all asset files
const assetFiles = await glob(this.ASSET_PATTERNS, {
cwd: appPath,
ignore: this.ASSET_IGNORE,
absolute: false,
onlyFiles: true,
});
if (assetFiles.length === 0) {
return 0;
}
// Ensure the assets output directory exists
await fs.ensureDir(assetsOutputDir);
// Copy each asset file, preserving directory structure
for (const assetFile of assetFiles) {
const sourcePath = path.join(appPath, assetFile);
// Compute the relative path within assets/
// Remove src/assets/ or assets/ prefix
let relativePath = assetFile;
if (relativePath.startsWith('src/assets/')) {
relativePath = relativePath.slice('src/assets/'.length);
} else if (relativePath.startsWith('assets/')) {
relativePath = relativePath.slice('assets/'.length);
}
const destPath = path.join(assetsOutputDir, relativePath);
// Ensure the destination directory exists
await fs.ensureDir(path.dirname(destPath));
// Copy the file
await fs.copy(sourcePath, destPath);
}
return assetFiles.length;
}
}

View file

@ -0,0 +1,6 @@
export * from './types';
export { BuildService } from './build.service';
export { ViteBuildRunner } from './vite-build-runner';
export { BuildManifestWriter } from './build-manifest-writer';
export { BuildWatcher } from './build-watcher';
export { TarballService } from './tarball.service';

View file

@ -0,0 +1,67 @@
import * as fs from 'fs-extra';
import path from 'path';
import archiver from 'archiver';
/**
* TarballService creates distributable .tar.gz archives from built applications.
*/
export class TarballService {
/**
* Create a tarball from the build output directory.
*
* @param sourceDir - The directory containing built files
* @param outputPath - The path for the output .tar.gz file
* @returns The absolute path to the created tarball
*/
async create(options: {
sourceDir: string;
outputPath: string;
}): Promise<string> {
const { sourceDir, outputPath } = options;
// Ensure the source directory exists
if (!(await fs.pathExists(sourceDir))) {
throw new Error(`Source directory does not exist: ${sourceDir}`);
}
// Ensure the output directory exists
await fs.ensureDir(path.dirname(outputPath));
// Normalize the output path to have .tar.gz extension
const normalizedOutputPath = outputPath.endsWith('.tar.gz')
? outputPath
: `${outputPath}.tar.gz`;
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(normalizedOutputPath);
const archive = archiver('tar', {
gzip: true,
gzipOptions: { level: 9 }, // Maximum compression
});
output.on('close', () => {
resolve(normalizedOutputPath);
});
archive.on('error', (err: Error) => {
reject(err);
});
archive.on('warning', (err: Error & { code?: string }) => {
if (err.code === 'ENOENT') {
// Log warnings about missing files
console.warn('Archive warning:', err.message);
} else {
reject(err);
}
});
archive.pipe(output);
// Add the entire source directory to the archive
archive.directory(sourceDir, false);
archive.finalize();
});
}
}

View file

@ -0,0 +1,66 @@
import { type ApplicationManifest } from 'twenty-shared/application';
export type BuildOptions = {
appPath: string;
watch?: boolean;
tarball?: boolean;
};
export type BuildResult = {
outputDir: string;
manifest: ApplicationManifest;
builtFunctions: BuiltFunctionInfo[];
tarballPath?: string;
};
export type BuiltFunctionInfo = {
name: string;
universalIdentifier: string;
originalHandlerPath: string;
builtHandlerPath: string;
sourceMapPath?: string;
};
export type ViteBuildConfig = {
appPath: string;
entryPath: string;
outputDir: string;
outputFileName: string;
treeshake?: boolean;
external?: (string | RegExp)[];
/** Relative path from the output file to the generated folder */
generatedRelativePath?: string;
};
export type ViteBuildResult = {
success: boolean;
outputPath: string;
sourceMapPath?: string;
error?: Error;
};
export type BuildWatcherState =
| 'IDLE'
| 'ANALYZING'
| 'BUILDING'
| 'ERROR'
| 'SUCCESS';
export type RebuildDecision = {
shouldRebuild: boolean;
/** Specific function files that changed (only these need rebuilding) */
affectedFunctions: string[];
/** Shared utility files changed (requires rebuilding ALL functions) */
sharedFilesChanged: boolean;
/** Build config files changed (requires full rebuild): package.json, tsconfig.json, .env */
configChanged: boolean;
/** Manifest config changed (requires manifest regeneration only): application.config.ts */
manifestChanged: boolean;
rebuildGenerated: boolean;
assetsChanged: boolean;
changedFiles: string[];
};
export type BuildWatchHandle = {
stop: () => Promise<void>;
};

View file

@ -0,0 +1,245 @@
import { build, type InlineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import path from 'path';
import * as fs from 'fs-extra';
import { type ViteBuildConfig, type ViteBuildResult } from './types';
/**
* ViteBuildRunner handles the transpilation of TypeScript serverless functions
* into distributable JavaScript bundles using Vite's programmatic API.
*
* Key design decisions:
* - External node modules: Dependencies are installed on the server, not bundled
* - Source maps enabled: For debugging in production
* - No minification: Keeps code readable for debugging
* - ES module output: Modern module format for serverless environments
*/
export class ViteBuildRunner {
private defaultExternal: (string | RegExp)[] = [
// Node.js built-ins
'path',
'fs',
'crypto',
'stream',
'util',
'os',
'url',
'http',
'https',
'events',
'buffer',
'querystring',
'assert',
'zlib',
'net',
'tls',
'child_process',
'worker_threads',
// Twenty SDK packages - these will be provided at runtime
/^twenty-sdk/,
/^twenty-shared/,
// Internal SDK path aliases (for development apps using SDK internals)
/^@\//,
// Generated folder - built separately as a module
// Matches: ../generated, ../../generated, ./generated, etc.
/(?:^|\/)generated(?:\/|$)/,
];
/**
* Build a single serverless function entry point.
*/
async buildFunction(config: ViteBuildConfig): Promise<ViteBuildResult> {
const {
appPath,
entryPath,
outputDir,
outputFileName,
external,
generatedRelativePath,
} = config;
const absoluteEntryPath = path.resolve(appPath, entryPath);
const outputFilePath = path.join(outputDir, outputFileName);
// Verify the entry file exists
if (!(await fs.pathExists(absoluteEntryPath))) {
return {
success: false,
outputPath: outputFilePath,
error: new Error(`Entry file not found: ${absoluteEntryPath}`),
};
}
const viteConfig = this.createViteConfig({
appPath,
entryPath: absoluteEntryPath,
outputDir,
outputFileName,
treeshake: true,
external: external ?? this.defaultExternal,
generatedRelativePath,
});
try {
await build(viteConfig);
const sourceMapPath = `${outputFilePath}.map`;
const hasSourceMap = await fs.pathExists(sourceMapPath);
return {
success: true,
outputPath: outputFilePath,
sourceMapPath: hasSourceMap ? sourceMapPath : undefined,
};
} catch (error) {
return {
success: false,
outputPath: outputFilePath,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
/**
* Build the generated/ folder (GraphQL client) without tree-shaking.
* The GraphQL client may use dynamic imports, so we preserve all exports.
*/
async buildGenerated(config: ViteBuildConfig): Promise<ViteBuildResult> {
const { appPath, entryPath, outputDir, outputFileName, external } = config;
const absoluteEntryPath = path.resolve(appPath, entryPath);
const outputFilePath = path.join(outputDir, outputFileName);
// Verify the entry file exists
if (!(await fs.pathExists(absoluteEntryPath))) {
return {
success: false,
outputPath: outputFilePath,
error: new Error(`Entry file not found: ${absoluteEntryPath}`),
};
}
// When building generated, don't externalize generated imports
const generatedExternal =
external ??
this.defaultExternal.filter(
(ext) =>
!(ext instanceof RegExp && ext.source.includes('generated')),
);
const viteConfig = this.createViteConfig({
appPath,
entryPath: absoluteEntryPath,
outputDir,
outputFileName,
treeshake: false, // Preserve all exports for dynamic imports
external: generatedExternal,
});
try {
await build(viteConfig);
const sourceMapPath = `${outputFilePath}.map`;
const hasSourceMap = await fs.pathExists(sourceMapPath);
return {
success: true,
outputPath: outputFilePath,
sourceMapPath: hasSourceMap ? sourceMapPath : undefined,
};
} catch (error) {
return {
success: false,
outputPath: outputFilePath,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
/**
* Build multiple functions in parallel for improved performance.
*/
async buildFunctionsParallel(
configs: ViteBuildConfig[],
): Promise<Map<string, ViteBuildResult>> {
const results = new Map<string, ViteBuildResult>();
const buildPromises = configs.map(async (config) => {
const result = await this.buildFunction(config);
return { name: config.outputFileName, result };
});
const buildResults = await Promise.all(buildPromises);
for (const { name, result } of buildResults) {
results.set(name, result);
}
return results;
}
/**
* Create Vite configuration for building a single entry point.
*/
private createViteConfig(options: {
appPath: string;
entryPath: string;
outputDir: string;
outputFileName: string;
treeshake: boolean;
external: (string | RegExp)[];
generatedRelativePath?: string;
}): InlineConfig {
const {
appPath,
entryPath,
outputDir,
outputFileName,
treeshake,
external,
generatedRelativePath,
} = options;
return {
root: appPath,
plugins: [
tsconfigPaths({
root: appPath,
}),
],
build: {
outDir: outputDir,
emptyOutDir: false, // Don't clear the output directory
lib: {
entry: entryPath,
formats: ['es'],
fileName: () => outputFileName,
},
rollupOptions: {
external,
treeshake,
output: {
// Preserve named exports
preserveModules: false,
exports: 'named',
// Rewrite external import paths
paths: generatedRelativePath
? (id: string) => {
// Rewrite generated imports to point to the correct location
if (/(?:^|\/)generated(?:\/|$)/.test(id)) {
return generatedRelativePath;
}
return id;
}
: undefined,
},
},
minify: false,
sourcemap: true,
},
logLevel: 'warn',
// Ensure we're running in a clean environment
configFile: false,
};
}
}

View file

@ -0,0 +1,65 @@
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/constants/current-execution-directory';
import { type ApiResponse } from '@/cli/types/api-response.types';
import { BuildService } from '@/cli/build/build.service';
import { type BuildResult } from '@/cli/build/types';
export type BuildCommandOptions = {
appPath?: string;
watch?: boolean;
tarball?: boolean;
};
/**
* AppBuildCommand handles the `twenty app build` CLI command.
*
* This command transpiles TypeScript applications into distributable
* JavaScript bundles using Vite.
*
* Usage:
* - `npx twenty app build [appPath]` - One-time build
* - `npx twenty app build --watch [appPath]` - Watch mode with incremental rebuilds
* - `npx twenty app build --tarball [appPath]` - Build + create .tar.gz
*/
export class AppBuildCommand {
private buildService = new BuildService();
async execute(options: BuildCommandOptions): Promise<ApiResponse<BuildResult>> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
if (options.watch) {
// Watch mode - this runs indefinitely
const watchHandle = await this.buildService.watch({
appPath,
watch: true,
tarball: options.tarball,
});
// Setup graceful shutdown
this.setupGracefulShutdown(watchHandle.stop);
// Return success immediately - the watch loop is running
return {
success: true,
data: {
outputDir: `${appPath}/.twenty/output`,
manifest: {} as any, // Will be populated during build
builtFunctions: [],
},
};
}
// One-time build
return this.buildService.build({
appPath,
tarball: options.tarball,
});
}
private setupGracefulShutdown(stopFn: () => Promise<void>): void {
process.on('SIGINT', async () => {
console.log('\n🛑 Stopping build watch mode...');
await stopFn();
process.exit(0);
});
}
}

View file

@ -11,6 +11,7 @@ import { AppSyncCommand } from '@/cli/commands/app-sync.command';
import { formatPath } from '@/cli/utils/format-path';
import { AppGenerateCommand } from '@/cli/commands/app-generate.command';
import { AppLogsCommand } from '@/cli/commands/app-logs.command';
import { AppBuildCommand } from '@/cli/commands/app-build.command';
export class AppCommand {
private devCommand = new AppDevCommand();
@ -19,6 +20,7 @@ export class AppCommand {
private addCommand = new AppAddCommand();
private generateCommand = new AppGenerateCommand();
private logsCommand = new AppLogsCommand();
private buildCommand = new AppBuildCommand();
getCommand(): Command {
const appCommand = new Command('app');
@ -35,6 +37,25 @@ export class AppCommand {
});
});
appCommand
.command('build [appPath]')
.description('Build application for deployment')
.option('-w, --watch', 'Watch for changes and rebuild')
.option('-t, --tarball', 'Create a tarball after build')
.action(async (appPath, options) => {
try {
const result = await this.buildCommand.execute({
...options,
appPath: formatPath(appPath),
});
if (!result.success) {
process.exit(1);
}
} catch {
process.exit(1);
}
});
appCommand
.command('sync [appPath]')
.description('Sync application to Twenty')

View file

@ -2,14 +2,79 @@ import { createJiti } from 'jiti';
import * as fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import { parseJsoncFile } from './jsonc-parser';
// Create a jiti instance for loading TypeScript config files
const createConfigLoader = () => {
return createJiti(fileURLToPath(import.meta.url), {
/**
* Read tsconfig.json paths and convert them to jiti aliases.
* Handles patterns like "generated/*": ["./generated/*"]
*/
const getTsconfigAliases = async (
appPath: string,
): Promise<Record<string, string>> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
if (!(await fs.pathExists(tsconfigPath))) {
return {};
}
try {
const tsconfig = await parseJsoncFile(tsconfigPath);
const paths = tsconfig?.compilerOptions?.paths as
| Record<string, string[]>
| undefined;
const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.';
if (!paths) {
return {};
}
const aliases: Record<string, string> = {};
for (const [pattern, targets] of Object.entries(paths)) {
if (targets.length === 0) continue;
// Remove trailing /* from pattern and target
const aliasKey = pattern.replace(/\/\*$/, '');
const targetPath = targets[0].replace(/\/\*$/, '');
// Resolve the target path relative to baseUrl and appPath
const resolvedTarget = path.resolve(appPath, baseUrl, targetPath);
aliases[aliasKey] = resolvedTarget;
}
return aliases;
} catch {
return {};
}
};
/**
* Create a jiti instance for loading TypeScript config files.
* When appPath is provided, jiti will use the app's tsconfig.json for path resolution.
*/
const createConfigLoader = async (appPath?: string) => {
const basePath = appPath ?? fileURLToPath(import.meta.url);
const options: {
moduleCache?: boolean;
fsCache?: boolean;
interopDefault?: boolean;
alias?: Record<string, string>;
} = {
moduleCache: false, // Don't cache during dev for hot reload
fsCache: false,
interopDefault: true,
});
};
// If appPath is provided, read tsconfig.json and set up aliases
if (appPath) {
const aliases = await getTsconfigAliases(appPath);
if (Object.keys(aliases).length > 0) {
options.alias = aliases;
}
}
return createJiti(basePath, options);
};
/**
@ -56,13 +121,19 @@ const findConfigExport = <T>(
* - `export default { ... }`
* - `export const anyName = { ... }` (any named export that is a plain object)
*
* @param filepath - Absolute path to the config file
* @param appPath - Optional app path for tsconfig.json path resolution
*
* @example
* ```typescript
* const config = await loadConfig<AppDefinition>('/path/to/src/app/application.config.ts');
* const config = await loadConfig<AppDefinition>('/path/to/src/app/application.config.ts', '/path/to/app');
* ```
*/
export const loadConfig = async <T>(filepath: string): Promise<T> => {
const jiti = createConfigLoader();
export const loadConfig = async <T>(
filepath: string,
appPath?: string,
): Promise<T> => {
const jiti = await createConfigLoader(appPath);
try {
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
@ -177,7 +248,7 @@ export const loadFunctionModule = async (
handlerName: string;
handlerPath: string;
}> => {
const jiti = createConfigLoader();
const jiti = await createConfigLoader(appPath);
try {
const mod = (await jiti.import(filepath)) as Record<string, unknown>;

View file

@ -70,7 +70,7 @@ const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
for (const filepath of objectFiles) {
try {
const manifest = await loadConfig<ObjectManifest>(filepath);
const manifest = await loadConfig<ObjectManifest>(filepath, appPath);
objects.push(manifest);
} catch (error) {
@ -99,7 +99,7 @@ const loadObjectExtensions = async (
for (const filepath of extensionFiles) {
try {
const manifest = await loadConfig<ObjectExtensionManifest>(filepath);
const manifest = await loadConfig<ObjectExtensionManifest>(filepath, appPath);
extensions.push(manifest);
} catch (error) {
@ -163,7 +163,7 @@ const loadRoles = async (appPath: string): Promise<RoleManifest[]> => {
for (const filepath of roleFiles) {
try {
const config = await loadConfig<RoleConfig>(filepath);
const config = await loadConfig<RoleConfig>(filepath, appPath);
roles.push(config);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
@ -303,7 +303,7 @@ export const loadManifest = async (
'app',
'application.config.ts',
);
const application = await loadConfig<Application>(applicationConfigPath);
const application = await loadConfig<Application>(applicationConfigPath, appPath);
// Load all entities in parallel
const [

View file

@ -1,7 +1,6 @@
import fs from 'fs';
import { pipeline } from 'stream/promises';
// @ts-expect-error legacy noImplicitAny
import archiver from 'archiver';
export const createZipFile = async (

View file

@ -22818,6 +22818,15 @@ __metadata:
languageName: node
linkType: hard
"@types/archiver@npm:^6.0.0":
version: 6.0.4
resolution: "@types/archiver@npm:6.0.4"
dependencies:
"@types/readdir-glob": "npm:*"
checksum: 10c0/ac971a5a72d55064b0a62ccebff643f220c957f109a68486c48b51c566df3a7fdbf5ddce8eb1bbd644c4c6486f882cf90044f5ebb08ce16dfe0b1cbb491f8c2c
languageName: node
linkType: hard
"@types/argparse@npm:1.0.38":
version: 1.0.38
resolution: "@types/argparse@npm:1.0.38"
@ -24393,6 +24402,15 @@ __metadata:
languageName: node
linkType: hard
"@types/readdir-glob@npm:*":
version: 1.1.5
resolution: "@types/readdir-glob@npm:1.1.5"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/46849136a3b5246105bca0303aab80552a9ff67e024e77ef1845a806a24c1a621dfcba0e4ee5a00ebad17f51edb80928f2dd6dc510a1d9897f3bc22ed64e5cbd
languageName: node
linkType: hard
"@types/resolve@npm:1.20.2":
version: 1.20.2
resolution: "@types/resolve@npm:1.20.2"
@ -56891,6 +56909,7 @@ __metadata:
dependencies:
"@genql/cli": "npm:^3.0.3"
"@sniptt/guards": "npm:^0.2.0"
"@types/archiver": "npm:^6.0.0"
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/jest": "npm:^29.5.0"
@ -56899,6 +56918,7 @@ __metadata:
"@types/lodash.kebabcase": "npm:^4.1.7"
"@types/lodash.startcase": "npm:^4"
"@types/node": "npm:^24.0.0"
archiver: "npm:^7.0.1"
axios: "npm:^1.6.0"
chalk: "npm:^5.3.0"
chokidar: "npm:^4.0.0"