From 70582d063e4f76cc1c612dafc4b651a6724fce1c Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 19 Jan 2026 18:43:40 +0100 Subject: [PATCH] Twenty SDK watch build of functions (#17252) ## Summary This PR adds function build support to the `twenty dev` command, enabling tree-shaken production builds of serverless functions during development with Vite's incremental build capabilities. ## Changes ### New Features - **Function building in dev mode**: Serverless functions are now compiled to tree-shaken bundles in `.twenty/functions/` during development - **Automatic restart on entry point changes**: When functions are added or removed, the watcher automatically restarts with the new configuration - **Function entry point logging**: Dev mode now displays detected function entry points with their names and paths --- .../src/cli/commands/app/app-dev.ts | 185 +++++++++--------- .../vite-plugin/vite-manifest-plugin.ts | 71 ------- .../vite/__tests__/entry-points.spec.ts | 110 +++++++++++ .../vite/__tests__/function-paths.spec.ts | 66 +++++++ .../src/cli/utilities/vite/dev-watcher.ts | 81 ++++++++ .../src/cli/utilities/vite/entry-points.ts | 18 ++ .../src/cli/utilities/vite/function-paths.ts | 91 +++++++++ .../src/cli/utilities/vite/index.ts | 25 +++ .../src/cli/utilities/vite/manifest-plugin.ts | 119 +++++++++++ 9 files changed, 602 insertions(+), 164 deletions(-) delete mode 100644 packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/__tests__/entry-points.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/__tests__/function-paths.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/index.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts diff --git a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts index bea421eeda..e1b3b91ffc 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,127 +1,126 @@ +import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; import { OUTPUT_DIR } from '@/cli/constants/output-dir'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; -import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; -import { type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build'; -import { - displayEntitySummary, - displayErrors, - displayWarnings, -} from '@/cli/utilities/manifest/utils/manifest-display'; import { + cleanupOldFunctions, + createDevWatcher, createManifestPlugin, - type ManifestBuildError, -} from '@/cli/utilities/vite-plugin/vite-manifest-plugin'; + runManifestBuild, + type BuildWatcher, + type ManifestPluginState, +} from '@/cli/utilities/vite'; import chalk from 'chalk'; import * as fs from 'fs-extra'; import path from 'path'; -import { createServer, type ViteDevServer } from 'vite'; export type AppDevOptions = { appPath?: string; }; export class AppDevCommand { - private server: ViteDevServer | null = null; + private watcher: BuildWatcher | null = null; private appPath: string = ''; + private isRestarting: boolean = false; + private manifestState: ManifestPluginState = { currentEntryPoints: [] }; async execute(options: AppDevOptions): Promise { this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - this.logStartupInfo(this.appPath); + console.log(chalk.blue('šŸš€ Starting Twenty Application Development Mode')); + console.log(chalk.gray(`šŸ“ App Path: ${this.appPath}`)); + console.log(''); - this.server = await this.createViteDevServer(this.appPath); - - await this.server.listen(); + await this.ensureOutputDirs(); + await this.startWatcher(); this.setupGracefulShutdown(); + } - console.log( - chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), + private async ensureOutputDirs(): Promise { + const outputDir = path.join(this.appPath, OUTPUT_DIR); + const functionsDir = path.join(outputDir, FUNCTIONS_DIR); + await fs.ensureDir(functionsDir); + } + + private async startWatcher(): Promise { + const functionInput = await runManifestBuild( + this.appPath, + this.manifestState, ); - } - private logStartupInfo(appPath: string): void { - console.log(chalk.blue('šŸš€ Starting Twenty Application Development Mode')); - console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); - console.log(''); - } + await cleanupOldFunctions(this.appPath, this.manifestState.currentEntryPoints); - private async createViteDevServer(appPath: string): Promise { - const manifestPlugin = createManifestPlugin({ - appPath, - onBuildStart: () => { - console.log(chalk.blue('šŸ”„ Building manifest...')); - }, - onBuildSuccess: (result: BuildManifestResult) => { - this.handleBuildSuccess(result); - }, - onBuildError: (error: ManifestBuildError) => { - this.handleBuildError(error); - }, - }); + const hasFunctions = Object.keys(functionInput).length > 0; - return createServer({ - root: appPath, - plugins: [manifestPlugin], - server: { - watch: { - ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'], - }, - port: 0, - open: false, - hmr: false, - }, - optimizeDeps: { - noDiscovery: true, - }, - logLevel: 'silent', - publicDir: false, - build: { - watch: { - include: [path.join(appPath, 'src/**')], - }, - }, - }); - } - - private handleBuildSuccess(result: BuildManifestResult): void { - displayEntitySummary(result.manifest); - - displayWarnings(result.warnings); - - this.writeManifestToOutput(result); - } - - private handleBuildError(error: ManifestBuildError): void { - if (error.errors) { - displayErrors(new ManifestValidationError(error.errors)); + if (hasFunctions) { + console.log(chalk.blue(' šŸ“¦ Building functions...')); } else { - console.error(chalk.red(' āœ— Build failed:'), error.message); + console.log(chalk.gray(' No functions to build')); } + + const manifestPlugin = createManifestPlugin( + this.appPath, + this.manifestState, + { + onEntryPointsChange: (newEntryPoints) => { + console.log(chalk.yellow(`šŸ”„ Entry points changed: ${JSON.stringify(newEntryPoints)}`)); + this.scheduleRestart(); + }, + }, + ); + + this.watcher = await createDevWatcher({ + appPath: this.appPath, + functionInput, + plugins: [manifestPlugin], + }); + + this.watcher.on('event', (event) => { + if (event.code === 'END') { + if (hasFunctions) { + console.log(chalk.green(' āœ“ Functions built')); + } + console.log(''); + console.log( + chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), + ); + } else if (event.code === 'ERROR') { + console.error(chalk.red(' āœ— Build error:'), event.error?.message); + } + }); } - private async writeManifestToOutput( - result: BuildManifestResult, - ): Promise { + private scheduleRestart(): void { + if (this.isRestarting) { + return; + } + + setImmediate(() => { + this.restartWatcher(); + }); + } + + private async restartWatcher(): Promise { + if (this.isRestarting) { + return; + } + + this.isRestarting = true; + try { - const outputDir = path.join(this.appPath, OUTPUT_DIR); - - await fs.ensureDir(outputDir); - - const manifestPath = path.join(outputDir, 'manifest.json'); - - await fs.writeJSON(manifestPath, result.manifest, { spaces: 2 }); - - console.log(chalk.green(` āœ“ Manifest written to ${manifestPath}`)); - console.log(''); console.log( - chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), - ); - } catch (error) { - console.error( - chalk.red(' āœ— Failed to write manifest:'), - error instanceof Error ? error.message : error, + chalk.yellow('šŸ”„ Function entry points changed, restarting watcher...'), ); + + if (this.watcher) { + await this.watcher.close(); + } + + await this.startWatcher(); + + console.log(chalk.green('āœ“ Watcher restarted with new entry points')); + } finally { + this.isRestarting = false; } } @@ -129,8 +128,8 @@ export class AppDevCommand { process.on('SIGINT', async () => { console.log(chalk.yellow('\nšŸ›‘ Stopping development mode...')); - if (this.server) { - await this.server.close(); + if (this.watcher) { + await this.watcher.close(); } process.exit(0); diff --git a/packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts b/packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts deleted file mode 100644 index d60aff5f87..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; -import { - buildManifest, - type BuildManifestResult, -} from '@/cli/utilities/manifest/utils/manifest-build'; -import { type Plugin } from 'vite'; - -const PLUGIN_NAME = 'twenty-manifest'; - -export type ManifestBuildError = { - message: string; - errors?: Array<{ path: string; message: string }>; -}; - -export type ManifestPluginOptions = { - appPath: string; - onBuildStart?: () => void; - onBuildSuccess?: (result: BuildManifestResult) => void; - onBuildError?: (error: ManifestBuildError) => void; -}; - -/** - * Creates a Vite plugin that builds the application manifest on startup - * and rebuilds it when source files change. - */ -export const createManifestPlugin = ( - options: ManifestPluginOptions, -): Plugin => { - const { appPath, onBuildStart, onBuildSuccess, onBuildError } = options; - - const runBuild = async (): Promise => { - onBuildStart?.(); - - try { - const result = await buildManifest(appPath); - - onBuildSuccess?.(result); - } catch (error) { - const buildError: ManifestBuildError = { - message: error instanceof Error ? error.message : String(error), - }; - - if (error instanceof ManifestValidationError) { - buildError.errors = error.errors; - } - - onBuildError?.(buildError); - } - }; - - return { - name: PLUGIN_NAME, - - buildStart: async () => { - await runBuild(); - }, - - handleHotUpdate: async ({ file }) => { - const relevantExtensions = ['.ts', '.json']; - const isRelevantFile = relevantExtensions.some((ext) => - file.endsWith(ext), - ); - - if (isRelevantFile) { - await runBuild(); - } - - return []; - }, - }; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/__tests__/entry-points.spec.ts b/packages/twenty-sdk/src/cli/utilities/vite/__tests__/entry-points.spec.ts new file mode 100644 index 0000000000..d261f8735d --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/__tests__/entry-points.spec.ts @@ -0,0 +1,110 @@ +import { + extractFunctionEntryPoints, + haveFunctionEntryPointsChanged, +} from '../entry-points'; + +describe('extractFunctionEntryPoints', () => { + it('should return empty array for no functions', () => { + const result = extractFunctionEntryPoints([]); + + expect(result).toEqual([]); + }); + + it('should extract handler paths from serverless functions', () => { + const functions = [ + { handlerPath: 'src/app/hello.function.ts' }, + { handlerPath: 'src/app/goodbye.function.ts' }, + ]; + + const result = extractFunctionEntryPoints(functions); + + expect(result).toEqual([ + 'src/app/goodbye.function.ts', + 'src/app/hello.function.ts', + ]); + }); + + it('should return sorted array', () => { + const functions = [ + { handlerPath: 'src/app/zebra.function.ts' }, + { handlerPath: 'src/app/alpha.function.ts' }, + { handlerPath: 'src/app/middle.function.ts' }, + ]; + + const result = extractFunctionEntryPoints(functions); + + expect(result).toEqual([ + 'src/app/alpha.function.ts', + 'src/app/middle.function.ts', + 'src/app/zebra.function.ts', + ]); + }); +}); + +describe('haveFunctionEntryPointsChanged', () => { + it('should return true for empty current and non-empty new', () => { + const current: string[] = []; + const newPoints = ['src/app/hello.function.ts']; + + const result = haveFunctionEntryPointsChanged(current, newPoints); + + expect(result).toBe(true); + }); + + it('should return true for non-empty current and empty new', () => { + const current = ['src/app/hello.function.ts']; + const newPoints: string[] = []; + + const result = haveFunctionEntryPointsChanged(current, newPoints); + + expect(result).toBe(true); + }); + + it('should return false for identical arrays', () => { + const current = ['src/app/alpha.function.ts', 'src/app/beta.function.ts']; + const newPoints = [ + 'src/app/alpha.function.ts', + 'src/app/beta.function.ts', + ]; + + const result = haveFunctionEntryPointsChanged(current, newPoints); + + expect(result).toBe(false); + }); + + it('should return true when an entry point is added', () => { + const current = ['src/app/alpha.function.ts']; + const newPoints = [ + 'src/app/alpha.function.ts', + 'src/app/beta.function.ts', + ]; + + const result = haveFunctionEntryPointsChanged(current, newPoints); + + expect(result).toBe(true); + }); + + it('should return true when an entry point is removed', () => { + const current = ['src/app/alpha.function.ts', 'src/app/beta.function.ts']; + const newPoints = ['src/app/alpha.function.ts']; + + const result = haveFunctionEntryPointsChanged(current, newPoints); + + expect(result).toBe(true); + }); + + it('should return true when entry points differ in content', () => { + const current = ['src/app/alpha.function.ts']; + const newPoints = ['src/app/beta.function.ts']; + + const result = haveFunctionEntryPointsChanged(current, newPoints); + + expect(result).toBe(true); + }); + + it('should return false for both empty arrays', () => { + const result = haveFunctionEntryPointsChanged([], []); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/vite/__tests__/function-paths.spec.ts b/packages/twenty-sdk/src/cli/utilities/vite/__tests__/function-paths.spec.ts new file mode 100644 index 0000000000..40330bc4f4 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/__tests__/function-paths.spec.ts @@ -0,0 +1,66 @@ +import { computeFunctionOutputPath } from '../function-paths'; + +describe('computeFunctionOutputPath', () => { + it('should handle function in src/app root', () => { + const result = computeFunctionOutputPath('src/app/hello.function.ts'); + + expect(result).toEqual({ + relativePath: 'hello.function.js', + outputDir: '', + }); + }); + + it('should handle function in subdirectory', () => { + const result = computeFunctionOutputPath('src/app/utils/greet.function.ts'); + + expect(result).toEqual({ + relativePath: 'utils/greet.function.js', + outputDir: 'utils', + }); + }); + + it('should handle deeply nested function', () => { + const result = computeFunctionOutputPath( + 'src/app/modules/auth/handlers/login.function.ts', + ); + + expect(result).toEqual({ + relativePath: 'modules/auth/handlers/login.function.js', + outputDir: 'modules/auth/handlers', + }); + }); + + it('should handle src/ prefix without app/', () => { + const result = computeFunctionOutputPath('src/handlers/process.function.ts'); + + expect(result).toEqual({ + relativePath: 'handlers/process.function.js', + outputDir: 'handlers', + }); + }); + + it('should handle path without src/ prefix', () => { + const result = computeFunctionOutputPath('handlers/webhook.function.ts'); + + expect(result).toEqual({ + relativePath: 'handlers/webhook.function.js', + outputDir: 'handlers', + }); + }); + + it('should normalize Windows path separators', () => { + const result = computeFunctionOutputPath('src\\app\\utils\\greet.function.ts'); + + expect(result).toEqual({ + relativePath: 'utils/greet.function.js', + outputDir: 'utils', + }); + }); + + it('should change .ts extension to .js', () => { + const result = computeFunctionOutputPath('src/app/test.function.ts'); + + expect(result.relativePath.endsWith('.js')).toBe(true); + expect(result.relativePath.endsWith('.ts')).toBe(false); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts b/packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts new file mode 100644 index 0000000000..d82e111864 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts @@ -0,0 +1,81 @@ +import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; +import { GENERATED_DIR } from '@/cli/constants/generated-dir'; +import { OUTPUT_DIR } from '@/cli/constants/output-dir'; +import path from 'path'; +import { build, type InlineConfig, type Rollup } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export const EXTERNAL_MODULES: (string | RegExp)[] = [ + 'path', 'fs', 'crypto', 'stream', 'util', 'os', 'url', 'http', 'https', + 'events', 'buffer', 'querystring', 'assert', 'zlib', 'net', 'tls', + 'child_process', 'worker_threads', + /^twenty-sdk/, /^twenty-shared/, /^@\//, /(?:^|\/)generated(?:\/|$)/, +]; + +export type DevWatcherOptions = { + appPath: string; + functionInput: Record; + plugins?: InlineConfig['plugins']; +}; + +export type BuildWatcher = Rollup.RollupWatcher; + +export const createDevWatcherConfig = (options: DevWatcherOptions): InlineConfig => { + const { appPath, functionInput, plugins = [] } = options; + + const outputDir = path.join(appPath, OUTPUT_DIR); + const functionsOutputDir = path.join(outputDir, FUNCTIONS_DIR); + const hasFunctions = Object.keys(functionInput).length > 0; + + // Use application.config.ts as placeholder when no functions exist + const entry = hasFunctions + ? functionInput + : { __placeholder__: path.join(appPath, 'src/app/application.config.ts') }; + + return { + root: appPath, + plugins: [ + tsconfigPaths({ root: appPath }), + ...plugins, + ], + build: { + outDir: functionsOutputDir, + emptyOutDir: false, + watch: { + include: ['src/**/*.ts', 'src/**/*.json'], + exclude: ['node_modules/**', '.twenty/**', 'dist/**'], + }, + lib: { + entry, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.js`, + }, + rollupOptions: { + external: hasFunctions ? EXTERNAL_MODULES : [/.*/], + treeshake: hasFunctions, + output: { + preserveModules: false, + exports: 'named', + paths: (id: string) => { + if (/(?:^|\/)generated(?:\/|$)/.test(id)) { + return `../${GENERATED_DIR}/index.js`; + } + return id; + }, + }, + }, + minify: false, + sourcemap: hasFunctions, + }, + logLevel: 'silent', + configFile: false, + }; +}; + +export const createDevWatcher = async ( + options: DevWatcherOptions, +): Promise => { + const config = createDevWatcherConfig(options); + const watcher = await build(config); + return watcher as BuildWatcher; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts b/packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts new file mode 100644 index 0000000000..f074f227ba --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts @@ -0,0 +1,18 @@ +export const extractFunctionEntryPoints = ( + serverlessFunctions: Array<{ handlerPath: string }>, +): string[] => { + return serverlessFunctions.map((fn) => fn.handlerPath).sort(); +}; + +export const haveFunctionEntryPointsChanged = ( + currentEntryPoints: string[], + newEntryPoints: string[], +): boolean => { + if (currentEntryPoints.length !== newEntryPoints.length) { + return true; + } + + return newEntryPoints.some( + (entryPoint, index) => entryPoint !== currentEntryPoints[index], + ); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts b/packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts new file mode 100644 index 0000000000..982a0fe09f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts @@ -0,0 +1,91 @@ +import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; +import { OUTPUT_DIR } from '@/cli/constants/output-dir'; +import * as fs from 'fs-extra'; +import path from 'path'; + +// src/app/hello.function.ts → { relativePath: 'hello.function.js', outputDir: '' } +// src/app/utils/greet.function.ts → { relativePath: 'utils/greet.function.js', outputDir: 'utils' } +export const computeFunctionOutputPath = ( + handlerPath: string, +): { relativePath: string; outputDir: string } => { + const normalizedPath = handlerPath.replace(/\\/g, '/'); + + let relativePath = normalizedPath; + if (relativePath.startsWith('src/app/')) { + relativePath = relativePath.slice('src/app/'.length); + } else if (relativePath.startsWith('src/')) { + relativePath = relativePath.slice('src/'.length); + } + + relativePath = relativePath.replace(/\.ts$/, '.js'); + + const outputDir = path.dirname(relativePath); + const normalizedOutputDir = outputDir === '.' ? '' : outputDir; + + return { + relativePath, + outputDir: normalizedOutputDir, + }; +}; + +export const buildFunctionInput = ( + appPath: string, + handlerPaths: Array<{ handlerPath: string }>, +): Record => { + const input: Record = {}; + + for (const fn of handlerPaths) { + const { relativePath } = computeFunctionOutputPath(fn.handlerPath); + const chunkName = relativePath.replace(/\.js$/, ''); + input[chunkName] = path.join(appPath, fn.handlerPath); + } + + return input; +}; + +export const cleanupOldFunctions = async ( + appPath: string, + currentEntryPoints: string[], +): Promise => { + const functionsDir = path.join(appPath, OUTPUT_DIR, FUNCTIONS_DIR); + + if (!(await fs.pathExists(functionsDir))) { + return; + } + + const expectedFiles = new Set( + currentEntryPoints.map((entryPoint) => { + const { relativePath } = computeFunctionOutputPath(entryPoint); + return relativePath; + }), + ); + + const expectedFilesWithMaps = new Set(); + for (const file of expectedFiles) { + expectedFilesWithMaps.add(file); + expectedFilesWithMaps.add(`${file}.map`); + } + + const removeOrphans = async (dir: string, relativeBase: string = ''): Promise => { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + await removeOrphans(fullPath, relativePath); + const remaining = await fs.readdir(fullPath); + if (remaining.length === 0) { + await fs.remove(fullPath); + } + } else if (entry.isFile()) { + if (!expectedFilesWithMaps.has(relativePath)) { + await fs.remove(fullPath); + } + } + } + }; + + await removeOrphans(functionsDir); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/index.ts b/packages/twenty-sdk/src/cli/utilities/vite/index.ts new file mode 100644 index 0000000000..9a59b405d3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/index.ts @@ -0,0 +1,25 @@ +export { + createDevWatcher, + createDevWatcherConfig, + EXTERNAL_MODULES, + type BuildWatcher, + type DevWatcherOptions +} from './dev-watcher'; + +export { + extractFunctionEntryPoints, + haveFunctionEntryPointsChanged +} from './entry-points'; + +export { + buildFunctionInput, + cleanupOldFunctions, + computeFunctionOutputPath +} from './function-paths'; + +export { + createManifestPlugin, + runManifestBuild, + type ManifestPluginCallbacks, + type ManifestPluginState +} from './manifest-plugin'; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts b/packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts new file mode 100644 index 0000000000..c7a9508458 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts @@ -0,0 +1,119 @@ +import { OUTPUT_DIR } from '@/cli/constants/output-dir'; +import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; +import { + buildManifest, + type BuildManifestResult, +} from '@/cli/utilities/manifest/utils/manifest-build'; +import { + displayEntitySummary, + displayErrors, + displayWarnings, +} from '@/cli/utilities/manifest/utils/manifest-display'; +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import path from 'path'; +import { type Plugin } from 'vite'; + +import { + extractFunctionEntryPoints, + haveFunctionEntryPointsChanged, +} from './entry-points'; +import { buildFunctionInput } from './function-paths'; + +export type ManifestPluginCallbacks = { + onEntryPointsChange?: (entryPoints: string[]) => void; +}; + +export type ManifestPluginState = { + currentEntryPoints: string[]; +}; + +export const createManifestPlugin = ( + appPath: string, + state: ManifestPluginState, + callbacks: ManifestPluginCallbacks = {}, +): Plugin => { + const isRelevantFile = (file: string): boolean => { + return ['.ts', '.json'].some((ext) => file.endsWith(ext)); + }; + + return { + name: 'twenty-manifest', + watchChange: async (id) => { + if (isRelevantFile(id)) { + await runManifestBuild(appPath, state, callbacks); + } + }, + }; +}; + +export const runManifestBuild = async ( + appPath: string, + state: ManifestPluginState, + callbacks: ManifestPluginCallbacks = {}, +): Promise> => { + console.log(chalk.blue('šŸ”„ Building manifest...')); + + try { + const result = await buildManifest(appPath); + + displayEntitySummary(result.manifest); + displayWarnings(result.warnings); + + const functions = result.manifest.serverlessFunctions; + if (functions.length > 0) { + console.log(chalk.gray(` šŸ“ Function entry points:`)); + for (const fn of functions) { + const name = fn.name || fn.universalIdentifier; + console.log(chalk.gray(` - ${name} (${fn.handlerPath})`)); + } + } + + await writeManifestToOutput(appPath, result); + + const newEntryPoints = extractFunctionEntryPoints(functions); + const entryPointsChanged = haveFunctionEntryPointsChanged( + state.currentEntryPoints, + newEntryPoints, + ); + + const isInitialBuild = state.currentEntryPoints.length === 0; + state.currentEntryPoints = newEntryPoints; + + if (entryPointsChanged && !isInitialBuild && callbacks.onEntryPointsChange) { + callbacks.onEntryPointsChange(newEntryPoints); + } + + return buildFunctionInput(appPath, functions); + } catch (error) { + if (error instanceof ManifestValidationError) { + displayErrors(error); + } else { + console.error( + chalk.red(' āœ— Build failed:'), + error instanceof Error ? error.message : error, + ); + } + return {}; + } +}; + +const writeManifestToOutput = async ( + appPath: string, + result: BuildManifestResult, +): Promise => { + try { + const outputDir = path.join(appPath, OUTPUT_DIR); + await fs.ensureDir(outputDir); + + const manifestPath = path.join(outputDir, 'manifest.json'); + await fs.writeJSON(manifestPath, result.manifest, { spaces: 2 }); + + console.log(chalk.green(` āœ“ Manifest written to ${manifestPath}`)); + } catch (error) { + console.error( + chalk.red(' āœ— Failed to write manifest:'), + error instanceof Error ? error.message : error, + ); + } +};