From 5a94ef7dbbf7803266398541da7f0f9c18bc44c6 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 22 Jan 2026 13:04:43 +0100 Subject: [PATCH] Move sdk watcher back to esbuild (#17316) Demo of current state https://github.com/user-attachments/assets/034aff50-9981-4c81-b5ce-410a07b8dbc9 --- packages/twenty-sdk/package.json | 1 + .../app-dev.integration.spec.ts.snap | 25 --- .../app-dev/tests/console-output.tests.ts | 36 +++- .../app-dev/tests/functions.tests.ts | 12 ++ .../src/functions/test-function.function.ts | 3 +- .../app-dev/tests/console-output.tests.ts | 46 +++-- .../utils/get-output-by-prefix.util.ts | 15 ++ .../integration/utils/run-app-dev.util.ts | 6 +- .../src/cli/commands/app/app-dev.ts | 35 ++-- .../build/common/cleanup-removed-files.ts | 23 +++ .../src/cli/utilities/build/common/display.ts | 6 - .../src/cli/utilities/build/common/logger.ts | 50 +++++ .../front-component-watcher.ts | 157 ++++++++------- .../build/functions/function-watcher.ts | 183 ++++++++++-------- .../build/manifest/entities/application.ts | 27 ++- .../manifest/entities/front-component.ts | 10 +- .../build/manifest/entities/function.ts | 10 +- .../build/manifest/entities/object.ts | 6 +- .../utilities/build/manifest/entities/role.ts | 6 +- .../build/manifest/manifest-build.ts | 18 +- .../build/manifest/manifest-display.ts | 12 +- .../build/manifest/manifest-watcher.ts | 23 ++- yarn.lock | 1 + 23 files changed, 437 insertions(+), 274 deletions(-) delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap create mode 100644 packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/display.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/logger.ts diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 475ee0b4f3..a144b3199c 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -38,6 +38,7 @@ "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.4.0", + "esbuild": "^0.25.0", "fast-glob": "^3.3.0", "fs-extra": "^11.2.0", "graphql": "^16.8.1", diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap deleted file mode 100644 index 8d06c0e56b..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`rich-app app:dev > console output > should match expected output 1`] = ` -"šŸ‘©ā€šŸ’» Workspace - default -šŸš€ Starting Twenty Application Development Mode -šŸ“ App Path: /root.function.ts) - - greeting-function (src/functions/greeting.function.ts) - - test-function-2 (src/utils/test-function-2.util.ts) - - test-function (src/functions/test-function.function.ts) - āœ“ Found 4 front component(s) - šŸ“ Front component entry points: - - root-component (src/root.front-component.tsx) - - card-component (src/components/card.front-component.tsx) - - greeting-component (src/components/greeting.front-component.tsx) - - test-component (src/components/test.front-component.tsx) - āœ“ Found 2 role(s) - āœ“ Manifest written to /.twenty/output/manifest.json - šŸ“‚ Manifest watcher started - šŸ“¦ Building functions... - šŸŽØ Building front components... - āœ“ Functions built - -šŸ‘€ Watching for changes... (Press Ctrl+C to stop) - āœ“ Front components built" -`; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts index 280a015578..efb4773ac7 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts @@ -1,14 +1,42 @@ +import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util'; import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; -import { sanitizeOutput } from '@/cli/__tests__/integration/utils/sanitize-output.util'; export const defineConsoleOutputTests = ( getResult: () => RunCliCommandResult, ): void => { describe('console output', () => { - it('should match expected output', () => { - const result = getResult(); + it('should contain init messages', () => { + const output = getOutputByPrefix(getResult().output, 'init'); - expect(sanitizeOutput(result.output)).toMatchSnapshot(); + expect(output).toContain('[init] šŸš€ Starting Twenty Application Development Mode'); + expect(output).toContain('[init] šŸ“ App Path:'); + }); + + it('should contain manifest-watch messages', () => { + const output = getOutputByPrefix(getResult().output, 'manifest-watch'); + + expect(output).toContain('[manifest-watch] šŸ”„ Building...'); + expect(output).toContain('[manifest-watch] āœ“ Loaded "Hello World"'); + expect(output).toContain('[manifest-watch] āœ“ Found 2 object(s)'); + expect(output).toContain('[manifest-watch] āœ“ Found 4 function(s)'); + expect(output).toContain('[manifest-watch] āœ“ Found 4 front component(s)'); + expect(output).toContain('[manifest-watch] āœ“ Found 2 role(s)'); + expect(output).toContain('[manifest-watch] āœ“ Written to'); + expect(output).toContain('[manifest-watch] šŸ“‚ Watcher started'); + }); + + it('should contain functions-watch messages', () => { + const output = getOutputByPrefix(getResult().output, 'functions-watch'); + + expect(output).toContain('[functions-watch] šŸ“¦ Building...'); + expect(output).toContain('[functions-watch] āœ“ Built'); + }); + + it('should contain front-components-watch messages', () => { + const output = getOutputByPrefix(getResult().output, 'front-components-watch'); + + expect(output).toContain('[front-components-watch] šŸŽØ Building...'); + expect(output).toContain('[front-components-watch] āœ“ Built'); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts index d1de205593..ffb4c653b5 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts @@ -21,5 +21,17 @@ export const defineFunctionsTests = (appPath: string): void => { 'src/root.function.mjs.map', ]); }); + + it('should not create shared chunk files for utilities', async () => { + const functionsDir = join(appPath, '.twenty/output/functions'); + const files = await fs.readdir(functionsDir, { recursive: true }); + + // Chunk files have a hash suffix like "greeting.util-CipJsYK0.mjs" + const chunkFiles = files + .map((f) => f.toString()) + .filter((f) => f.endsWith('.mjs') && !f.includes('.function.mjs')); + + expect(chunkFiles).toEqual([]); + }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/test-function.function.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/test-function.function.ts index b547cb3aa8..bd1dfe8599 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/test-function.function.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/test-function.function.ts @@ -1,7 +1,8 @@ import { defineFunction } from '@/application/functions/define-function'; +import { formatFarewell } from '../utils/greeting.util'; const handler = () => { - return 'test-result'; + return formatFarewell('test'); }; export default defineFunction({ diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts index cc9bf71912..f77c91d22d 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts @@ -1,22 +1,42 @@ -import { type RunCliCommandResult } from '../../../../../integration/utils/run-cli-command.util'; +import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util'; +import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; export const defineConsoleOutputTests = ( getResult: () => RunCliCommandResult, ): void => { describe('console output', () => { - it('should contain key messages', () => { - const result = getResult(); - const output = result.output; + it('should contain init messages', () => { + const output = getOutputByPrefix(getResult().output, 'init'); - expect(output).toContain('Starting Twenty Application Development Mode'); - expect(output).toContain('Building manifest'); - expect(output).toContain('Loaded "Root App"'); - expect(output).toContain('Found 1 object(s)'); - expect(output).toContain('Found 1 function(s)'); - expect(output).toContain('Found 1 front component(s)'); - expect(output).toContain('Found 1 role(s)'); - expect(output).toContain('Manifest written to'); - expect(output).toContain('Functions built'); + expect(output).toContain('[init] šŸš€ Starting Twenty Application Development Mode'); + expect(output).toContain('[init] šŸ“ App Path:'); + }); + + it('should contain manifest-watch messages', () => { + const output = getOutputByPrefix(getResult().output, 'manifest-watch'); + + expect(output).toContain('[manifest-watch] šŸ”„ Building...'); + expect(output).toContain('[manifest-watch] āœ“ Loaded "Root App"'); + expect(output).toContain('[manifest-watch] āœ“ Found 1 object(s)'); + expect(output).toContain('[manifest-watch] āœ“ Found 1 function(s)'); + expect(output).toContain('[manifest-watch] āœ“ Found 1 front component(s)'); + expect(output).toContain('[manifest-watch] āœ“ Found 1 role(s)'); + expect(output).toContain('[manifest-watch] āœ“ Written to'); + expect(output).toContain('[manifest-watch] šŸ“‚ Watcher started'); + }); + + it('should contain functions-watch messages', () => { + const output = getOutputByPrefix(getResult().output, 'functions-watch'); + + expect(output).toContain('[functions-watch] šŸ“¦ Building...'); + expect(output).toContain('[functions-watch] āœ“ Built'); + }); + + it('should contain front-components-watch messages', () => { + const output = getOutputByPrefix(getResult().output, 'front-components-watch'); + + expect(output).toContain('[front-components-watch] šŸŽØ Building...'); + expect(output).toContain('[front-components-watch] āœ“ Built'); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts new file mode 100644 index 0000000000..fbfefeb5d6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts @@ -0,0 +1,15 @@ +export type LogPrefix = 'init' | 'manifest-watch' | 'functions-watch' | 'front-components-watch'; + +export const getOutputByPrefix = (output: string, prefix: LogPrefix): string => { + const prefixPattern = `[${prefix}]`; + const lines = output.split('\n'); + + return lines + .filter((line) => line.includes(prefixPattern)) + .map((line) => { + // Remove ANSI color codes for snapshot comparison + const cleanLine = line.replace(/\x1B\[[0-9;]*m/g, ''); + return cleanLine.trim(); + }) + .join('\n'); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts index fce5389668..40ed5cb693 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts @@ -13,9 +13,9 @@ export const runAppDev = (options: RunAppDevOptions): Promise { this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - console.log(chalk.blue('šŸš€ Starting Twenty Application Development Mode')); - console.log(chalk.gray(`šŸ“ App Path: ${this.appPath}`)); + initLogger.log('šŸš€ Starting Twenty Application Development Mode'); + initLogger.log(`šŸ“ App Path: ${this.appPath}`); console.log(''); await this.startWatchers(); @@ -89,26 +91,13 @@ export class AppDevCommand { } private setupGracefulShutdown(): void { - process.on('SIGINT', async () => { - console.log(chalk.yellow('\nšŸ›‘ Stopping development mode...')); - - const closePromises: Promise[] = []; - - if (this.manifestWatcher) { - closePromises.push(this.manifestWatcher.close()); - } - - if (this.functionsWatcher) { - closePromises.push(this.functionsWatcher.close()); - } - - if (this.frontComponentsWatcher) { - closePromises.push(this.frontComponentsWatcher.close()); - } - - await Promise.all(closePromises); - + const shutdown = () => { + console.log(''); + initLogger.warn('šŸ›‘ Stopping...'); process.exit(0); - }); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts b/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts new file mode 100644 index 0000000000..517822b513 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts @@ -0,0 +1,23 @@ +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import path from 'path'; + +export const cleanupRemovedFiles = async ( + outputDir: string, + oldPaths: string[], + newPaths: string[], +): Promise => { + const newPathsSet = new Set(newPaths); + const removedPaths = oldPaths.filter((p) => !newPathsSet.has(p)); + + for (const removedPath of removedPaths) { + const outputBaseName = removedPath.replace(/\.tsx?$/, '.mjs'); + const outputFile = path.join(outputDir, outputBaseName); + const sourceMapFile = `${outputFile}.map`; + + await fs.remove(outputFile); + await fs.remove(sourceMapFile); + + console.log(chalk.gray(` šŸ—‘ļø Removed ${outputBaseName}`)); + } +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/display.ts b/packages/twenty-sdk/src/cli/utilities/build/common/display.ts deleted file mode 100644 index 12d34e033d..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/display.ts +++ /dev/null @@ -1,6 +0,0 @@ -import chalk from 'chalk'; - -export const printWatchingMessage = (): void => { - console.log(''); - console.log(chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)')); -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts b/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts new file mode 100644 index 0000000000..01cdba40d3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts @@ -0,0 +1,50 @@ +import chalk, { type ChalkInstance } from 'chalk'; + +export type LoggerContext = + | 'init' + | 'manifest-watch' + | 'functions-watch' + | 'front-components-watch'; + +type LoggerConfig = { + prefix: string; + color: ChalkInstance; +}; + +const LOGGER_CONFIGS: Record = { + init: { + prefix: '[init]', + color: chalk.cyan, + }, + 'manifest-watch': { + prefix: '[manifest-watch]', + color: chalk.magenta, + }, + 'functions-watch': { + prefix: '[functions-watch]', + color: chalk.yellow, + }, + 'front-components-watch': { + prefix: '[front-components-watch]', + color: chalk.green, + }, +}; + +export type Logger = { + log: (message: string) => void; + success: (message: string) => void; + error: (message: string) => void; + warn: (message: string) => void; +}; + +export const createLogger = (context: LoggerContext): Logger => { + const config = LOGGER_CONFIGS[context]; + const prefix = config.color(config.prefix); + + return { + log: (message: string) => console.log(`${prefix} ${message}`), + success: (message: string) => console.log(`${prefix} ${chalk.green(message)}`), + error: (message: string) => console.error(`${prefix} ${chalk.red(message)}`), + warn: (message: string) => console.log(`${prefix} ${chalk.yellow(message)}`), + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts index 9dbc109321..2cd1988d80 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts @@ -1,10 +1,9 @@ -import chalk from 'chalk'; +import * as esbuild from 'esbuild'; import * as fs from 'fs-extra'; import path from 'path'; -import { build, type InlineConfig, type Rollup } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; +import { cleanupRemovedFiles } from '../common/cleanup-removed-files'; import { OUTPUT_DIR } from '../common/constants'; -import { printWatchingMessage } from '../common/display'; +import { createLogger } from '../common/logger'; import { type RestartableWatcher, type RestartableWatcherOptions, @@ -12,20 +11,23 @@ import { import { type ManifestBuildResult } from '../manifest/manifest-build'; import { FRONT_COMPONENTS_DIR } from './constants'; -export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [ +const logger = createLogger('front-components-watch'); + +export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [ 'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', - /^twenty-sdk/, - /^twenty-shared/, - /^@\//, + 'twenty-sdk', + 'twenty-sdk/*', + 'twenty-shared', + 'twenty-shared/*', ]; export class FrontComponentsWatcher implements RestartableWatcher { private appPath: string; private componentPaths: string[]; - private innerWatcher: Rollup.RollupWatcher | null = null; + private esBuildContext: esbuild.BuildContext | null = null; private isRestarting = false; constructor(options: RestartableWatcherOptions) { @@ -42,104 +44,99 @@ export class FrontComponentsWatcher implements RestartableWatcher { async start(): Promise { const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); - await fs.ensureDir(outputDir); + await fs.emptyDir(outputDir); if (this.componentPaths.length > 0) { - console.log(chalk.blue(' šŸŽØ Building front components...')); - this.innerWatcher = await this.createWatcher(); + logger.log('šŸŽØ Building...'); + await this.createContext(); } else { - console.log(chalk.gray(' No front components to build')); - printWatchingMessage(); + logger.log('No front components to build'); + logger.log('šŸ‘€ Watching for changes...'); } } async close(): Promise { - await this.innerWatcher?.close(); + await this.esBuildContext?.dispose(); + this.esBuildContext = null; } async restart(result: ManifestBuildResult): Promise { - if (this.isRestarting) { - return; - } + if (this.isRestarting) return; this.isRestarting = true; - try { - console.log(chalk.yellow('šŸ”„ Restarting front components watcher...')); - await this.innerWatcher?.close(); - this.innerWatcher = null; + logger.warn('šŸ”„ Restarting...'); + await this.close(); - this.componentPaths = result.filePaths.frontComponents; + const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); + const newPaths = result.filePaths.frontComponents; + await cleanupRemovedFiles(outputDir, this.componentPaths, newPaths); + this.componentPaths = newPaths; if (this.componentPaths.length > 0) { - console.log(chalk.blue(' šŸŽØ Building front components...')); - this.innerWatcher = await this.createWatcher(); + logger.log('šŸŽØ Building...'); + await this.createContext(); } else { - console.log(chalk.gray(' No front components to build')); - printWatchingMessage(); + logger.log('No front components to build'); + logger.log('šŸ‘€ Watching for changes...'); } - console.log(chalk.green('āœ“ Front components watcher restarted')); + logger.success('āœ“ Restarted'); } finally { this.isRestarting = false; } } - private async createWatcher(): Promise { - const config = this.createConfig(); - const watcher = await build(config) as Rollup.RollupWatcher; + private async createContext(): Promise { + const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); - watcher.on('event', (event) => { - if (event.code === 'END') { - console.log(chalk.green(' āœ“ Front components built')); - printWatchingMessage(); - } else if (event.code === 'ERROR') { - console.error(chalk.red(' āœ— Front component build error:'), event.error?.message); - } + const entryPoints: Record = {}; + for (const componentPath of this.componentPaths) { + const entryName = componentPath.replace(/\.tsx?$/, ''); + entryPoints[entryName] = path.join(this.appPath, componentPath); + } + + this.esBuildContext = await esbuild.context({ + entryPoints, + bundle: true, + splitting: false, + format: 'esm', + outdir: outputDir, + outExtension: { '.js': '.mjs' }, + external: FRONT_COMPONENT_EXTERNAL_MODULES, + tsconfig: path.join(this.appPath, 'tsconfig.json'), + jsx: 'automatic', + sourcemap: true, + metafile: true, + logLevel: 'silent', + plugins: [ + { + name: 'build-notifications', + setup: (build) => { + build.onEnd((result) => { + if (result.errors.length > 0) { + logger.error('āœ— Build error:'); + for (const error of result.errors) { + logger.error(` ${error.text}`); + } + } else { + const outputs = Object.keys(result.metafile?.outputs ?? {}) + .filter((file) => file.endsWith('.mjs')) + .map((file) => path.relative(outputDir, file)); + + for (const output of outputs) { + logger.success(`āœ“ Built ${output}`); + } + logger.log('šŸ‘€ Watching for changes...'); + } + }); + }, + }, + ], }); - return watcher; - } + await this.esBuildContext.rebuild(); - private createConfig(): InlineConfig { - const frontComponentsOutputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); - - const entries = Object.fromEntries( - this.componentPaths.map((filePath) => [ - filePath.replace(/\.tsx?$/, ''), - path.join(this.appPath, filePath), - ]), - ); - - return { - root: this.appPath, - plugins: [ - tsconfigPaths({ root: this.appPath }), - ], - esbuild: { - jsx: 'automatic', - }, - build: { - outDir: frontComponentsOutputDir, - emptyOutDir: false, - watch: { - include: ['**/*.ts', '**/*.tsx', '**/*.json'], - exclude: ['node_modules/**', '.twenty/**', 'dist/**'], - }, - lib: { - entry: entries, - formats: ['es'], - fileName: (_, entryName) => `${entryName}.mjs`, - }, - rollupOptions: { - external: FRONT_COMPONENT_EXTERNAL_MODULES, - treeshake: true, - }, - minify: false, - sourcemap: true, - }, - logLevel: 'silent', - configFile: false, - }; + await this.esBuildContext.watch(); } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts index 5417283f39..42cf43f726 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts @@ -1,10 +1,9 @@ -import chalk from 'chalk'; +import * as esbuild from 'esbuild'; import * as fs from 'fs-extra'; import path from 'path'; -import { build, type InlineConfig, type Rollup } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; +import { cleanupRemovedFiles } from '../common/cleanup-removed-files'; import { OUTPUT_DIR } from '../common/constants'; -import { printWatchingMessage } from '../common/display'; +import { createLogger } from '../common/logger'; import { type RestartableWatcher, type RestartableWatcherOptions, @@ -12,17 +11,37 @@ import { import { type ManifestBuildResult } from '../manifest/manifest-build'; import { FUNCTIONS_DIR } from './constants'; -export const FUNCTION_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(?:\/|$)/, +const logger = createLogger('functions-watch'); + +export const FUNCTION_EXTERNAL_MODULES: string[] = [ + 'path', + 'fs', + 'crypto', + 'stream', + 'util', + 'os', + 'url', + 'http', + 'https', + 'events', + 'buffer', + 'querystring', + 'assert', + 'zlib', + 'net', + 'tls', + 'child_process', + 'worker_threads', + 'twenty-sdk', + 'twenty-sdk/*', + 'twenty-shared', + 'twenty-shared/*', ]; export class FunctionsWatcher implements RestartableWatcher { private appPath: string; private functionPaths: string[]; - private innerWatcher: Rollup.RollupWatcher | null = null; + private esBuildContext: esbuild.BuildContext | null = null; private isRestarting = false; constructor(options: RestartableWatcherOptions) { @@ -39,101 +58,109 @@ export class FunctionsWatcher implements RestartableWatcher { async start(): Promise { const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); - await fs.ensureDir(outputDir); + await fs.emptyDir(outputDir); if (this.functionPaths.length > 0) { - console.log(chalk.blue(' šŸ“¦ Building functions...')); - this.innerWatcher = await this.createWatcher(); + logger.log('šŸ“¦ Building...'); + await this.createContext(); } else { - console.log(chalk.gray(' No functions to build')); - printWatchingMessage(); + logger.log('No functions to build'); + logger.log('šŸ‘€ Watching for changes...'); } } async close(): Promise { - await this.innerWatcher?.close(); + await this.esBuildContext?.dispose(); + this.esBuildContext = null; } async restart(result: ManifestBuildResult): Promise { - if (this.isRestarting) { - return; - } + if (this.isRestarting) return; this.isRestarting = true; - try { - console.log(chalk.yellow('šŸ”„ Restarting functions watcher...')); - await this.innerWatcher?.close(); - this.innerWatcher = null; + logger.warn('šŸ”„ Restarting...'); + await this.close(); - this.functionPaths = result.filePaths.functions; + const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); + const newPaths = result.filePaths.functions; + await cleanupRemovedFiles(outputDir, this.functionPaths, newPaths); + this.functionPaths = newPaths; if (this.functionPaths.length > 0) { - console.log(chalk.blue(' šŸ“¦ Building functions...')); - this.innerWatcher = await this.createWatcher(); + logger.log('šŸ“¦ Building...'); + await this.createContext(); } else { - console.log(chalk.gray(' No functions to build')); - printWatchingMessage(); + logger.log('No functions to build'); + logger.log('šŸ‘€ Watching for changes...'); } - console.log(chalk.green('āœ“ Functions watcher restarted')); + logger.success('āœ“ Restarted'); } finally { this.isRestarting = false; } } - private async createWatcher(): Promise { - const config = this.createConfig(); - const watcher = await build(config) as Rollup.RollupWatcher; + private async createContext(): Promise { + const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); - watcher.on('event', (event) => { - if (event.code === 'END') { - console.log(chalk.green(' āœ“ Functions built')); - printWatchingMessage(); - } else if (event.code === 'ERROR') { - console.error(chalk.red(' āœ— Function build error:'), event.error?.message); - } + const entryPoints: Record = {}; + for (const functionPath of this.functionPaths) { + const entryName = functionPath.replace(/\.tsx?$/, ''); + entryPoints[entryName] = path.join(this.appPath, functionPath); + } + + this.esBuildContext = await esbuild.context({ + entryPoints, + bundle: true, + splitting: false, + format: 'esm', + platform: 'node', + outdir: outputDir, + outExtension: { '.js': '.mjs' }, + external: FUNCTION_EXTERNAL_MODULES, + tsconfig: path.join(this.appPath, 'tsconfig.json'), + sourcemap: true, + metafile: true, + logLevel: 'silent', + plugins: [ + { + name: 'external-patterns', + setup: (build) => { + // Externalize paths containing "generated" (matches /(?:^|\/)generated(?:\/|$)/) + build.onResolve({ filter: /(?:^|\/)generated(?:\/|$)/ }, (args) => ({ + path: args.path, + external: true, + })); + }, + }, + { + name: 'build-notifications', + setup: (build) => { + build.onEnd((result) => { + if (result.errors.length > 0) { + logger.error('āœ— Build error:'); + for (const error of result.errors) { + logger.error(` ${error.text}`); + } + } else { + const outputs = Object.keys(result.metafile?.outputs ?? {}) + .filter((file) => file.endsWith('.mjs')) + .map((file) => path.relative(outputDir, file)); + + for (const output of outputs) { + logger.success(`āœ“ Built ${output}`); + } + logger.log('šŸ‘€ Watching for changes...'); + } + }); + }, + }, + ], }); - return watcher; - } + await this.esBuildContext.rebuild(); - private createConfig(): InlineConfig { - const functionsOutputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); - - const entries = Object.fromEntries( - this.functionPaths.map((filePath) => [ - filePath.replace(/\.tsx?$/, ''), - path.join(this.appPath, filePath), - ]), - ); - - return { - root: this.appPath, - plugins: [ - tsconfigPaths({ root: this.appPath }), - ], - build: { - outDir: functionsOutputDir, - emptyOutDir: false, - watch: { - include: ['**/*.ts', '**/*.tsx', '**/*.json'], - exclude: ['node_modules/**', '.twenty/**', 'dist/**'], - }, - lib: { - entry: entries, - formats: ['es'], - fileName: (_, entryName) => `${entryName}.mjs`, - }, - rollupOptions: { - external: FUNCTION_EXTERNAL_MODULES, - treeshake: true, - }, - minify: false, - sourcemap: true, - }, - logLevel: 'silent', - configFile: false, - }; + await this.esBuildContext.watch(); } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts index 0e8d57a533..062d7b844a 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts @@ -1,7 +1,7 @@ -import chalk from 'chalk'; -import * as fs from 'fs-extra'; +import { glob } from 'fast-glob'; import path from 'path'; import { type Application } from 'twenty-shared/application'; +import { createLogger } from '../../common/logger'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { @@ -11,14 +11,25 @@ import { type ManifestWithoutSources, } from './entity.interface'; -const findApplicationConfigPath = async (appPath: string): Promise => { - const configFile = path.join(appPath, 'application.config.ts'); +const logger = createLogger('manifest-watch'); - if (await fs.pathExists(configFile)) { - return configFile; +const findApplicationConfigPath = async (appPath: string): Promise => { + const files = await glob('**/application.config.ts', { + cwd: appPath, + ignore: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'], + }); + + if (files.length === 0) { + throw new Error('Missing application.config.ts in your app'); } - throw new Error('Missing application.config.ts in your app root'); + if (files.length > 1) { + throw new Error( + `Multiple application.config.ts files found: ${files.join(', ')}. Only one is allowed.`, + ); + } + + return path.join(appPath, files[0]); }; export class ApplicationEntityBuilder @@ -57,7 +68,7 @@ export class ApplicationEntityBuilder display(applications: Application[]): void { const application = applications[0]; const appName = application?.displayName ?? 'Application'; - console.log(chalk.green(` āœ“ Loaded "${appName}"`)); + logger.success(`āœ“ Loaded "${appName}"`); } findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts index 5b8b662452..f2ddb24d24 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts @@ -1,6 +1,6 @@ -import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type FrontComponentManifest } from 'twenty-shared/application'; +import { createLogger } from '../../common/logger'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { @@ -10,6 +10,8 @@ import { type ManifestWithoutSources, } from './entity.interface'; +const logger = createLogger('manifest-watch'); + type FrontComponentConfig = Omit & { component: { name: string }; }; @@ -67,13 +69,13 @@ export class FrontComponentEntityBuilder } display(components: FrontComponentManifest[]): void { - console.log(chalk.green(` āœ“ Found ${components.length} front component(s)`)); + logger.success(`āœ“ Found ${components.length} front component(s)`); if (components.length > 0) { - console.log(chalk.gray(` šŸ“ Front component entry points:`)); + logger.log('šŸ“ Entry points:'); for (const component of components) { const name = component.name || component.universalIdentifier; - console.log(chalk.gray(` - ${name} (${component.componentPath})`)); + logger.log(` - ${name} (${component.componentPath})`); } } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts index 0817b4f910..6f40538f13 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts @@ -1,6 +1,6 @@ -import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type ServerlessFunctionManifest } from 'twenty-shared/application'; +import { createLogger } from '../../common/logger'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { @@ -10,6 +10,8 @@ import { type ManifestWithoutSources, } from './entity.interface'; +const logger = createLogger('manifest-watch'); + export class FunctionEntityBuilder implements ManifestEntityBuilder { @@ -112,13 +114,13 @@ export class FunctionEntityBuilder } display(functions: ServerlessFunctionManifest[]): void { - console.log(chalk.green(` āœ“ Found ${functions.length} function(s)`)); + logger.success(`āœ“ Found ${functions.length} function(s)`); if (functions.length > 0) { - console.log(chalk.gray(` šŸ“ Function entry points:`)); + logger.log('šŸ“ Entry points:'); for (const fn of functions) { const name = fn.name || fn.universalIdentifier; - console.log(chalk.gray(` - ${name} (${fn.handlerPath})`)); + logger.log(` - ${name} (${fn.handlerPath})`); } } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts index 14d68a1086..04829fa678 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts @@ -1,8 +1,8 @@ -import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type ObjectManifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; import { isNonEmptyArray } from 'twenty-shared/utils'; +import { createLogger } from '../../common/logger'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { @@ -12,6 +12,8 @@ import { type ManifestWithoutSources, } from './entity.interface'; +const logger = createLogger('manifest-watch'); + export class ObjectEntityBuilder implements ManifestEntityBuilder { @@ -104,7 +106,7 @@ export class ObjectEntityBuilder } display(objects: ObjectManifest[]): void { - console.log(chalk.green(` āœ“ Found ${objects.length} object(s)`)); + logger.success(`āœ“ Found ${objects.length} object(s)`); } findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts index 61f468c191..0e368b057a 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts @@ -1,6 +1,6 @@ -import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type RoleManifest } from 'twenty-shared/application'; +import { createLogger } from '../../common/logger'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { @@ -10,6 +10,8 @@ import { type ManifestWithoutSources, } from './entity.interface'; +const logger = createLogger('manifest-watch'); + export class RoleEntityBuilder implements ManifestEntityBuilder { async build(appPath: string): Promise> { const roleFiles = await glob(['**/*.role.ts'], { @@ -57,7 +59,7 @@ export class RoleEntityBuilder implements ManifestEntityBuilder { } display(roles: RoleManifest[]): void { - console.log(chalk.green(` āœ“ Found ${roles?.length ?? 'no'} role(s)`)); + logger.success(`āœ“ Found ${roles?.length ?? 'no'} role(s)`); } findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 80c8814663..331fdf8049 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -1,12 +1,12 @@ import { findPathFile } from '@/cli/utilities/file/utils/file-find'; import { parseJsoncFile } from '@/cli/utilities/file/utils/file-jsonc'; -import chalk from 'chalk'; import { glob } from 'fast-glob'; import * as fs from 'fs-extra'; import path, { relative, sep } from 'path'; import { type ApplicationManifest } from 'twenty-shared/application'; import { type Sources } from 'twenty-shared/types'; import { OUTPUT_DIR } from '../common/constants'; +import { createLogger } from '../common/logger'; import { applicationEntityBuilder } from './entities/application'; import { frontComponentEntityBuilder } from './entities/front-component'; import { functionEntityBuilder } from './entities/function'; @@ -18,6 +18,8 @@ import { manifestExtractFromFileServer } from './manifest-extract-from-file-serv import { validateManifest } from './manifest-validate'; import { ManifestValidationError } from './manifest.types'; +const logger = createLogger('manifest-watch'); + export type EntityFilePaths = { application: string[]; objects: string[]; @@ -67,11 +69,10 @@ const writeManifestToOutput = async ( const manifestPath = path.join(outputDir, 'manifest.json'); await fs.writeJSON(manifestPath, manifest, { spaces: 2 }); - console.log(chalk.green(` āœ“ Manifest written to ${manifestPath}`)); + logger.success(`āœ“ Written to ${manifestPath}`); } catch (error) { - console.error( - chalk.red(' āœ— Failed to write manifest:'), - error instanceof Error ? error.message : error, + logger.error( + `āœ— Failed to write: ${error instanceof Error ? error.message : error}`, ); } }; @@ -102,7 +103,7 @@ export const runManifestBuild = async ( const { display = true, writeOutput = true } = options; if (display) { - console.log(chalk.blue('šŸ”„ Building manifest...')); + logger.log('šŸ”„ Building...'); } try { @@ -189,9 +190,8 @@ export const runManifestBuild = async ( if (error instanceof ManifestValidationError) { displayErrors(error); } else { - console.error( - chalk.red(' āœ— Build failed:'), - error instanceof Error ? error.message : error, + logger.error( + `āœ— Build failed: ${error instanceof Error ? error.message : error}`, ); } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts index 54e07175b2..c610227b39 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts @@ -1,5 +1,5 @@ -import chalk from 'chalk'; import { type ApplicationManifest } from 'twenty-shared/application'; +import { createLogger } from '../common/logger'; import { applicationEntityBuilder } from './entities/application'; import { frontComponentEntityBuilder } from './entities/front-component'; import { functionEntityBuilder } from './entities/function'; @@ -7,6 +7,8 @@ import { objectEntityBuilder } from './entities/object'; import { roleEntityBuilder } from './entities/role'; import { type ManifestValidationError, type ValidationWarning } from './manifest.types'; +const logger = createLogger('manifest-watch'); + export const displayEntitySummary = (manifest: ApplicationManifest): void => { applicationEntityBuilder.display( manifest.application ? [manifest.application] : [], @@ -18,11 +20,10 @@ export const displayEntitySummary = (manifest: ApplicationManifest): void => { }; export const displayErrors = (error: ManifestValidationError): void => { - console.log(chalk.red('\n āœ— Manifest validation failed:\n')); + logger.error('āœ— Validation failed:'); for (const err of error.errors) { - console.log(chalk.red(` • ${err.path}: ${err.message}`)); + logger.error(` • ${err.path}: ${err.message}`); } - console.log(''); }; export const displayWarnings = (warnings: ValidationWarning[]): void => { @@ -30,9 +31,8 @@ export const displayWarnings = (warnings: ValidationWarning[]): void => { return; } - console.log(''); for (const warning of warnings) { const path = warning.path ? `${warning.path}: ` : ''; - console.log(chalk.yellow(` ⚠ ${path}${warning.message}`)); + logger.warn(`⚠ ${path}${warning.message}`); } }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts index 73138f36f2..3f58073e98 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts @@ -1,9 +1,10 @@ -import chalk from 'chalk'; import chokidar, { type FSWatcher } from 'chokidar'; import path from 'path'; -import { printWatchingMessage } from '../common/display'; +import { createLogger } from '../common/logger'; import { runManifestBuild, type ManifestBuildResult } from './manifest-build'; +const logger = createLogger('manifest-watch'); + export type ManifestWatcherCallbacks = { onBuildSuccess?: (result: ManifestBuildResult) => void; }; @@ -25,7 +26,12 @@ export class ManifestWatcher { async start(): Promise { this.watcher = chokidar.watch(this.appPath, { - ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'], + ignored: [ + '**/node_modules/**', + '**/.twenty/**', + '**/dist/**', + (filePath: string) => filePath.includes('/.twenty/') || filePath.includes('\\.twenty\\'), + ], ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 100, @@ -38,17 +44,22 @@ export class ManifestWatcher { return; } - console.log(chalk.gray(` File ${event}: ${path.relative(this.appPath, filePath)}`)); + // Double-check to prevent watching our own output + if (filePath.includes('.twenty')) { + return; + } + + logger.log(`File ${event}: ${path.relative(this.appPath, filePath)}`); const result = await runManifestBuild(this.appPath); if (result.manifest) { - printWatchingMessage(); + logger.log('šŸ‘€ Watching for changes...'); this.callbacks.onBuildSuccess?.(result); } }); - console.log(chalk.gray(' šŸ“‚ Manifest watcher started')); + logger.log('šŸ“‚ Watcher started'); } async close(): Promise { diff --git a/yarn.lock b/yarn.lock index 6ea82ab3ff..2d11d8c5e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56976,6 +56976,7 @@ __metadata: chokidar: "npm:^4.0.0" commander: "npm:^12.0.0" dotenv: "npm:^16.4.0" + esbuild: "npm:^0.25.0" fast-glob: "npm:^3.3.0" fs-extra: "npm:^11.2.0" graphql: "npm:^16.8.1"