mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
b001991047
commit
5cef07af45
15 changed files with 1547 additions and 16 deletions
|
|
@ -75,6 +75,7 @@ export default defineConfig(() => {
|
|||
'path',
|
||||
'fs',
|
||||
'child_process',
|
||||
'util',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
67
packages/twenty-sdk/src/cli/build/build-manifest-writer.ts
Normal file
67
packages/twenty-sdk/src/cli/build/build-manifest-writer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
238
packages/twenty-sdk/src/cli/build/build-watcher.ts
Normal file
238
packages/twenty-sdk/src/cli/build/build-watcher.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
663
packages/twenty-sdk/src/cli/build/build.service.ts
Normal file
663
packages/twenty-sdk/src/cli/build/build.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
packages/twenty-sdk/src/cli/build/index.ts
Normal file
6
packages/twenty-sdk/src/cli/build/index.ts
Normal 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';
|
||||
67
packages/twenty-sdk/src/cli/build/tarball.service.ts
Normal file
67
packages/twenty-sdk/src/cli/build/tarball.service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
66
packages/twenty-sdk/src/cli/build/types.ts
Normal file
66
packages/twenty-sdk/src/cli/build/types.ts
Normal 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>;
|
||||
};
|
||||
245
packages/twenty-sdk/src/cli/build/vite-build-runner.ts
Normal file
245
packages/twenty-sdk/src/cli/build/vite-build-runner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
65
packages/twenty-sdk/src/cli/commands/app-build.command.ts
Normal file
65
packages/twenty-sdk/src/cli/commands/app-build.command.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
20
yarn.lock
20
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue