From 5ee6853d7e1bb57faacb0d362a0ccea0b0013e68 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 21 Jan 2026 16:18:54 +0100 Subject: [PATCH] Rework SDK watcher (#17305) --- packages/twenty-sdk/package.json | 5 +- .../cli/commands/function/function-execute.ts | 3 +- .../front-components/front-component-paths.ts | 14 ++ .../front-component-watcher.ts | 48 ++---- .../__tests__/function-paths.spec.ts | 34 +--- .../build/functions/function-paths.ts | 14 +- .../build/functions/function-watcher.ts | 8 +- .../build/manifest/entities/application.ts | 17 +- .../manifest/entities/front-component.ts | 7 +- .../build/manifest/entities/function.ts | 5 +- .../manifest/entities/object-extension.ts | 12 +- .../build/manifest/entities/object.ts | 7 +- .../utilities/build/manifest/entities/role.ts | 4 +- .../build/manifest/manifest-build.ts | 2 + .../manifest-extract-from-file-server.ts | 160 ++++++++++++++++++ .../build/manifest/manifest-file-extractor.ts | 101 ----------- .../build/manifest/manifest-validate.ts | 8 +- .../build/manifest/manifest-watcher.ts | 108 +++--------- .../build/manifest/vite-module-loader.ts | 111 ------------ yarn.lock | 23 +-- 20 files changed, 264 insertions(+), 427 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-file-extractor.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/vite-module-loader.ts diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 2007a866cf..475ee0b4f3 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -35,6 +35,7 @@ "archiver": "^7.0.1", "axios": "^1.6.0", "chalk": "^5.3.0", + "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.4.0", "fast-glob": "^3.3.0", @@ -44,9 +45,7 @@ "inquirer": "^10.0.0", "jsonc-parser": "^3.2.0", "lodash.camelcase": "^4.3.0", - "lodash.capitalize": "^4.2.1", "lodash.kebabcase": "^4.1.1", - "lodash.startcase": "^4.4.0", "typescript": "^5.9.2", "uuid": "^13.0.0", "vite": "^7.0.0", @@ -57,9 +56,7 @@ "@types/fs-extra": "^11.0.0", "@types/inquirer": "^9.0.0", "@types/lodash.camelcase": "^4.3.7", - "@types/lodash.capitalize": "^4", "@types/lodash.kebabcase": "^4.1.7", - "@types/lodash.startcase": "^4", "@types/node": "^24.0.0", "@types/react": "^19.0.2", "tsx": "^4.7.0", diff --git a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts index 0e00346d74..f249fdcc7a 100644 --- a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts @@ -3,6 +3,7 @@ import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build' import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import chalk from 'chalk'; import { type ApplicationManifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; export class FunctionExecuteCommand { private apiService = new ApiService(); @@ -119,7 +120,7 @@ export class FunctionExecuteCommand { console.log(`${chalk.bold('Duration:')} ${executionResult.duration}ms`); - if (executionResult.data !== undefined && executionResult.data !== null) { + if (isDefined(executionResult.data)) { console.log(''); console.log(chalk.bold('Data:')); console.log(chalk.white(JSON.stringify(executionResult.data, null, 2))); diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts new file mode 100644 index 0000000000..34ec28ea0f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts @@ -0,0 +1,14 @@ +export const computeFrontComponentOutputPath = ( + componentPath: string, +): string => { + const normalizedPath = componentPath.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); + } + + return relativePath.replace(/\.tsx?$/, '.js'); +}; 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 df37a7f433..6a58eed4b8 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,7 +1,7 @@ import chalk from 'chalk'; import * as fs from 'fs-extra'; import path from 'path'; -import type { ApplicationManifest, FrontComponentManifest } from 'twenty-shared/application'; +import type { ApplicationManifest } from 'twenty-shared/application'; import { build, type InlineConfig, type Rollup } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; import { OUTPUT_DIR } from '../common/constants'; @@ -11,6 +11,22 @@ import { type RestartableWatcherOptions, } from '../common/restartable-watcher.interface'; import { FRONT_COMPONENTS_DIR } from './constants'; +import { computeFrontComponentOutputPath } from './front-component-paths'; + +const buildFrontComponentEntries = ( + appPath: string, + componentPaths: Array<{ componentPath: string }>, +): Record => { + const entries: Record = {}; + + for (const component of componentPaths) { + const relativePath = computeFrontComponentOutputPath(component.componentPath); + const chunkName = relativePath.replace(/\.js$/, ''); + entries[chunkName] = path.join(appPath, component.componentPath); + } + + return entries; +}; export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [ 'react', @@ -19,34 +35,6 @@ export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [ 'react/jsx-dev-runtime', ]; -const computeOutputPath = (sourcePath: string): string => { - const normalizedPath = sourcePath.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); - } - - return relativePath.replace(/\.tsx?$/, '.js'); -}; - -const buildFrontComponentEntries = ( - appPath: string, - components: FrontComponentManifest[], -): Record => { - const entries: Record = {}; - - for (const component of components) { - const relativePath = computeOutputPath(component.componentPath); - const chunkName = relativePath.replace(/\.js$/, ''); - entries[chunkName] = path.join(appPath, component.componentPath); - } - - return entries; -}; - export class FrontComponentsWatcher implements RestartableWatcher { private appPath: string; private entries: Record; @@ -159,7 +147,7 @@ export class FrontComponentsWatcher implements RestartableWatcher { outDir: frontComponentsOutputDir, emptyOutDir: false, watch: { - include: ['src/**/*.tsx', 'src/**/*.ts', 'src/**/*.json'], + include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], exclude: ['node_modules/**', '.twenty/**', 'dist/**'], }, lib: { diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts index 40330bc4f4..da966adb7a 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts @@ -4,19 +4,13 @@ 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: '', - }); + expect(result).toBe('hello.function.js'); }); 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', - }); + expect(result).toBe('utils/greet.function.js'); }); it('should handle deeply nested function', () => { @@ -24,43 +18,31 @@ describe('computeFunctionOutputPath', () => { 'src/app/modules/auth/handlers/login.function.ts', ); - expect(result).toEqual({ - relativePath: 'modules/auth/handlers/login.function.js', - outputDir: 'modules/auth/handlers', - }); + expect(result).toBe('modules/auth/handlers/login.function.js'); }); 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', - }); + expect(result).toBe('handlers/process.function.js'); }); it('should handle path without src/ prefix', () => { const result = computeFunctionOutputPath('handlers/webhook.function.ts'); - expect(result).toEqual({ - relativePath: 'handlers/webhook.function.js', - outputDir: 'handlers', - }); + expect(result).toBe('handlers/webhook.function.js'); }); 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', - }); + expect(result).toBe('utils/greet.function.js'); }); 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); + expect(result.endsWith('.js')).toBe(true); + expect(result.endsWith('.ts')).toBe(false); }); }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts index 17b9b2cf0d..4fae9e6c2a 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts @@ -1,8 +1,6 @@ -import path from 'path'; - export const computeFunctionOutputPath = ( handlerPath: string, -): { relativePath: string; outputDir: string } => { +): string => { const normalizedPath = handlerPath.replace(/\\/g, '/'); let relativePath = normalizedPath; @@ -12,13 +10,5 @@ export const computeFunctionOutputPath = ( relativePath = relativePath.slice('src/'.length); } - relativePath = relativePath.replace(/\.ts$/, '.js'); - - const outputDir = path.dirname(relativePath); - const normalizedOutputDir = outputDir === '.' ? '' : outputDir; - - return { - relativePath, - outputDir: normalizedOutputDir, - }; + return relativePath.replace(/\.ts$/, '.js'); }; 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 2076aff98c..263605c64b 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 @@ -20,7 +20,7 @@ const buildFunctionEntries = ( const entries: Record = {}; for (const fn of handlerPaths) { - const { relativePath } = computeFunctionOutputPath(fn.handlerPath); + const relativePath = computeFunctionOutputPath(fn.handlerPath); const chunkName = relativePath.replace(/\.js$/, ''); entries[chunkName] = path.join(appPath, fn.handlerPath); } @@ -50,7 +50,7 @@ export class FunctionsWatcher implements RestartableWatcher { } shouldRestart(manifest: ApplicationManifest): boolean { - const newEntries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions); + const newEntries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions ?? []); const currentKeys = Object.keys(this.entries).sort(); const newKeys = Object.keys(newEntries).sort(); @@ -96,7 +96,7 @@ export class FunctionsWatcher implements RestartableWatcher { await this.innerWatcher?.close(); this.innerWatcher = null; - this.entries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions); + this.entries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions ?? []); if (this.hasEntries()) { console.log(chalk.blue(' 📦 Building functions...')); @@ -144,7 +144,7 @@ export class FunctionsWatcher implements RestartableWatcher { outDir: functionsOutputDir, emptyOutDir: false, watch: { - include: ['src/**/*.ts', 'src/**/*.json'], + include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], exclude: ['node_modules/**', '.twenty/**', 'dist/**'], }, lib: { 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 4326ae3ad5..e0ebfff955 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,26 +1,21 @@ import chalk from 'chalk'; import path from 'path'; import { type Application } from 'twenty-shared/application'; -import { extractManifestFromFile } from '../manifest-file-extractor'; +import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { - type EntityIdWithLocation, - type ManifestEntityBuilder, - type ManifestWithoutSources, + type EntityIdWithLocation, + type ManifestEntityBuilder, + type ManifestWithoutSources, } from './entity.interface'; export class ApplicationEntityBuilder implements ManifestEntityBuilder { async build(appPath: string): Promise { - const applicationConfigPath = path.join( - appPath, - 'src', - 'app', - 'application.config.ts', - ); + const applicationConfigPath = path.join(appPath, 'src', 'app', 'application.config.ts'); - return extractManifestFromFile(applicationConfigPath, appPath); + return manifestExtractFromFileServer.extractManifestFromFile(applicationConfigPath); } validate(application: Application, errors: ValidationError[]): void { 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 d1c2d20a01..890f8e8a81 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 @@ -2,7 +2,7 @@ import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type FrontComponentManifest } from 'twenty-shared/application'; -import { extractManifestFromFile } from '../manifest-file-extractor'; +import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { type EntityIdWithLocation, @@ -25,10 +25,9 @@ export class FrontComponentEntityBuilder for (const filepath of componentFiles) { try { frontComponentManifests.push( - await extractManifestFromFile( + await manifestExtractFromFileServer.extractManifestFromFile( filepath, - appPath, - { entryProperty: 'component', jsx: true }, + { entryProperty: 'component' }, ), ); } catch (error) { 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 1906374a28..0c829cb19d 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 @@ -2,7 +2,7 @@ import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type ServerlessFunctionManifest } from 'twenty-shared/application'; -import { extractManifestFromFile } from '../manifest-file-extractor'; +import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { type EntityIdWithLocation, @@ -25,9 +25,8 @@ export class FunctionEntityBuilder for (const filepath of functionFiles) { try { functionManifests.push( - await extractManifestFromFile( + await manifestExtractFromFileServer.extractManifestFromFile( filepath, - appPath, { entryProperty: 'handler' }, ), ); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts index ea53b97a22..9bc8b6518d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts @@ -2,7 +2,8 @@ import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import { glob } from 'fast-glob'; import { type ObjectExtensionManifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; -import { extractManifestFromFile } from '../manifest-file-extractor'; +import { isNonEmptyArray } from 'twenty-shared/utils'; +import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { type EntityIdWithLocation, @@ -25,10 +26,7 @@ export class ObjectExtensionEntityBuilder for (const filepath of extensionFiles) { try { objectExtensionManifests.push( - await extractManifestFromFile( - filepath, - appPath, - ), + await manifestExtractFromFileServer.extractManifestFromFile(filepath), ); } catch (error) { const relPath = toPosixRelative(filepath, appPath); @@ -78,7 +76,7 @@ export class ObjectExtensionEntityBuilder }); } - if (!ext.fields || ext.fields.length === 0) { + if (!isNonEmptyArray(ext.fields)) { errors.push({ path: extPath, message: 'Object extension must have at least one field', @@ -112,7 +110,7 @@ export class ObjectExtensionEntityBuilder if ( (field.type === FieldMetadataType.SELECT || field.type === FieldMetadataType.MULTI_SELECT) && - (!Array.isArray(field.options) || field.options.length === 0) + !isNonEmptyArray(field.options) ) { errors.push({ path: fieldPath, 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 8da4548eb5..420081c267 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 @@ -3,7 +3,8 @@ import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type ObjectManifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; -import { extractManifestFromFile } from '../manifest-file-extractor'; +import { isNonEmptyArray } from 'twenty-shared/utils'; +import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { type EntityIdWithLocation, @@ -26,7 +27,7 @@ export class ObjectEntityBuilder for (const filepath of objectFiles) { try { objectManifests.push( - await extractManifestFromFile(filepath, appPath), + await manifestExtractFromFileServer.extractManifestFromFile(filepath), ); } catch (error) { const relPath = toPosixRelative(filepath, appPath); @@ -91,7 +92,7 @@ export class ObjectEntityBuilder if ( (field.type === FieldMetadataType.SELECT || field.type === FieldMetadataType.MULTI_SELECT) && - (!Array.isArray(field.options) || field.options.length === 0) + !isNonEmptyArray(field.options) ) { errors.push({ path: fieldPath, 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 f67164f781..1f37330ea8 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 @@ -2,7 +2,7 @@ import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type RoleManifest } from 'twenty-shared/application'; -import { extractManifestFromFile } from '../manifest-file-extractor'; +import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { type EntityIdWithLocation, @@ -23,7 +23,7 @@ export class RoleEntityBuilder implements ManifestEntityBuilder for (const filepath of roleFiles) { try { roleManifests.push( - await extractManifestFromFile(filepath, appPath), + await manifestExtractFromFileServer.extractManifestFromFile(filepath), ); } catch (error) { const relPath = toPosixRelative(filepath, appPath); 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 b7c8846eb2..0424f0b6d8 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 @@ -14,6 +14,7 @@ import { objectEntityBuilder } from './entities/object'; import { objectExtensionEntityBuilder } from './entities/object-extension'; import { roleEntityBuilder } from './entities/role'; import { displayEntitySummary, displayErrors, displayWarnings } from './manifest-display'; +import { manifestExtractFromFileServer } from './manifest-extract-from-file-server'; import { validateManifest } from './manifest-validate'; import { ManifestValidationError } from './manifest.types'; @@ -99,6 +100,7 @@ export const runManifestBuild = async ( try { await validateFolderStructure(appPath); + manifestExtractFromFileServer.init(appPath); const packageJson = await parseJsoncFile( await findPathFile(appPath, 'package.json'), diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts new file mode 100644 index 0000000000..4db0f3af3f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts @@ -0,0 +1,160 @@ +import * as fs from 'fs-extra'; +import path from 'path'; +import { isDefined, isPlainObject } from 'twenty-shared/utils'; +import { createServer, type ViteDevServer } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export type ExtractManifestOptions = { + entryProperty?: string; +}; + +export class ManifestExtractFromFileServer { + private server: ViteDevServer | null = null; + private appPath: string | null = null; + + init(appPath: string): void { + if (this.appPath !== appPath) { + this.closeViteServer(); + } + this.appPath = appPath; + } + + async extractManifestFromFile( + filepath: string, + options: ExtractManifestOptions = {}, + ): Promise { + if (!this.appPath) { + throw new Error('ManifestExtractFromFileServer not initialized. Call init(appPath) first.'); + } + + const { entryProperty } = options; + const server = await this.getServer(); + const module = (await server.ssrLoadModule(filepath)) as Record; + + const config = this.extractConfigFromModule>(module, entryProperty); + + if (!config) { + const expectedExport = entryProperty + ? `a config object with a "${entryProperty}" property` + : 'a config object (default export or any named object export)'; + throw new Error(`Config file ${filepath} must export ${expectedExport}`); + } + + if (!entryProperty) { + return config as TManifest; + } + + const entryFunction = config[entryProperty] as Function; + const entryName = entryFunction.name; + + if (!entryName) { + throw new Error(`${entryProperty} function in ${filepath} must be a named function`); + } + + const importSource = await this.resolveEntryPath(filepath, entryName); + const entryPath = importSource ?? path.relative(this.appPath, filepath).replace(/\\/g, '/'); + + const { [entryProperty]: _, ...configWithoutEntry } = config; + + return { + ...configWithoutEntry, + [`${entryProperty}Name`]: entryName, + [`${entryProperty}Path`]: entryPath, + } as TManifest; + } + + async closeViteServer(): Promise { + if (this.server) { + await this.server.close(); + this.server = null; + } + } + + private async getServer(): Promise { + if (!this.appPath) { + throw new Error('ManifestExtractFromFileServer not initialized. Call init(appPath) first.'); + } + + if (this.server) { + return this.server; + } + + this.server = await createServer({ + root: this.appPath, + plugins: [tsconfigPaths({ root: this.appPath })], + server: { middlewareMode: true }, + optimizeDeps: { disabled: true }, + logLevel: 'silent', + configFile: false, + esbuild: { jsx: 'automatic' }, + }); + + return this.server; + } + + private extractConfigFromModule( + module: Record, + entryProperty?: string, + ): T | undefined { + const hasValidEntry = (value: unknown): boolean => + isPlainObject(value) && + typeof (value as Record)[entryProperty!] === 'function'; + + if (isDefined(module.default) && (!entryProperty || hasValidEntry(module.default))) { + return module.default as T; + } + + for (const value of Object.values(module)) { + if (isPlainObject(value) && (!entryProperty || hasValidEntry(value))) { + return value as T; + } + } + + return undefined; + } + + private async resolveEntryPath( + filepath: string, + entryName: string, + ): Promise { + if (!this.appPath) { + return null; + } + + const source = await fs.readFile(filepath, 'utf8'); + + const patterns = [ + new RegExp(`import\\s*\\{[^}]*\\b${entryName}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`), + new RegExp(`import\\s+${entryName}\\s+from\\s*['"]([^'"]+)['"]`), + ]; + + let importSpecifier: string | null = null; + for (const pattern of patterns) { + const match = source.match(pattern); + if (match) { + importSpecifier = match[1]; + break; + } + } + + if (!importSpecifier) { + return null; + } + + const server = await this.getServer(); + const resolved = await server.pluginContainer.resolveId(importSpecifier, filepath); + if (resolved?.id) { + return path.relative(this.appPath, resolved.id).replace(/\\/g, '/'); + } + + if (importSpecifier.startsWith('.')) { + const absolutePath = path.resolve(path.dirname(filepath), importSpecifier); + const relativePath = path.relative(this.appPath, absolutePath); + return (relativePath.endsWith('.ts') ? relativePath : `${relativePath}.ts`).replace(/\\/g, '/'); + } + + return null; + } +} + +export const manifestExtractFromFileServer = new ManifestExtractFromFileServer(); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-file-extractor.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-file-extractor.ts deleted file mode 100644 index 9cfe4314bd..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-file-extractor.ts +++ /dev/null @@ -1,101 +0,0 @@ -import path from 'path'; -import { - closeViteServer, - findImportSource, - getViteServer, - loadModule, -} from './vite-module-loader'; - -export type ExtractManifestOptions = { - jsx?: boolean; - entryProperty?: string; -}; - -const findConfigInModule = ( - module: Record, - validator?: (value: unknown) => boolean, -): T | undefined => { - if (module.default !== undefined) { - if (!validator || validator(module.default)) { - return module.default as T; - } - } - - for (const [key, value] of Object.entries(module)) { - if (key === 'default') continue; - if (value === undefined || value === null) continue; - if (typeof value !== 'object') continue; - if (Array.isArray(value)) continue; - - if (!validator || validator(value)) { - return value as T; - } - } - - return undefined; -}; - -export const extractManifestFromFile = async ( - filepath: string, - appPath: string, - options: ExtractManifestOptions = {}, -): Promise => { - const { entryProperty } = options; - - // Get or create the Vite server for this appPath - const server = await getViteServer(appPath); - - // Load the module using Vite's SSR loader - const module = await loadModule(server, filepath); - - const configValidator = entryProperty - ? (value: unknown): boolean => - typeof value === 'object' && - value !== null && - entryProperty in value && - typeof (value as Record)[entryProperty] === 'function' - : undefined; - - const config = findConfigInModule>( - module, - configValidator, - ); - - if (!config) { - const expectedExport = entryProperty - ? `a config object with a "${entryProperty}" property` - : 'a config object (default export or any named object export)'; - throw new Error(`Config file ${filepath} must export ${expectedExport}`); - } - - if (!entryProperty) { - return config as TManifest; - } - - const entryFunction = config[entryProperty] as Function; - const entryName = entryFunction.name; - - if (!entryName) { - throw new Error( - `${entryProperty} function in ${filepath} must be a named function`, - ); - } - - // Use Vite to resolve where the function was imported from - const importSource = await findImportSource(server, filepath, entryName, appPath); - const entryPath = - importSource ?? path.relative(appPath, filepath).replace(/\\/g, '/'); - - const { [entryProperty]: _, ...configWithoutEntry } = config; - - const manifest = { - ...configWithoutEntry, - [`${entryProperty}Name`]: entryName, - [`${entryProperty}Path`]: entryPath, - }; - - return manifest as TManifest; -}; - -// Re-export for cleanup -export { closeViteServer }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index f752416281..08cf58ef93 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -1,3 +1,4 @@ +import { isNonEmptyArray } from 'twenty-shared/utils'; import { applicationEntityBuilder } from './entities/application'; import { type EntityIdWithLocation, @@ -48,16 +49,13 @@ export const validateManifest = ( }); } - if (!manifest.objects || manifest.objects.length === 0) { + if (!isNonEmptyArray(manifest.objects)) { warnings.push({ message: 'No objects defined in src/app/objects/', }); } - if ( - !manifest.serverlessFunctions || - manifest.serverlessFunctions.length === 0 - ) { + if (!isNonEmptyArray(manifest.serverlessFunctions)) { warnings.push({ message: 'No functions defined in src/app/functions/', }); 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 c8cda7cf02..a3078eb169 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,12 +1,8 @@ import chalk from 'chalk'; -import * as fs from 'fs-extra'; +import chokidar, { type FSWatcher } from 'chokidar'; import path from 'path'; import { type ApplicationManifest } from 'twenty-shared/application'; -import { build, type InlineConfig, type Plugin, type Rollup } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; -import { OUTPUT_DIR } from '../common/constants'; import { printWatchingMessage } from '../common/display'; -import { type RestartableWatcher } from '../common/restartable-watcher.interface'; import { runManifestBuild } from './manifest-build'; export type ManifestWatcherCallbacks = { @@ -18,30 +14,40 @@ export type ManifestWatcherOptions = { callbacks?: ManifestWatcherCallbacks; }; -export class ManifestWatcher implements RestartableWatcher { +export class ManifestWatcher { private appPath: string; private callbacks: ManifestWatcherCallbacks; - private innerWatcher: Rollup.RollupWatcher | null = null; + private watcher: FSWatcher | null = null; constructor(options: ManifestWatcherOptions) { this.appPath = options.appPath; this.callbacks = options.callbacks ?? {}; } - restart(_manifest: ApplicationManifest): Promise { - throw new Error('Method not implemented.'); - } - - shouldRestart(_oldManifest: ApplicationManifest | null, _newManifest: ApplicationManifest): boolean { - throw new Error('Method not implemented.'); - } async start(): Promise { - const config = this.createConfig(); - this.innerWatcher = await build(config) as Rollup.RollupWatcher; + const srcPath = path.join(this.appPath, 'src'); - this.innerWatcher.on('event', (event) => { - if (event.code === 'ERROR') { - console.error(chalk.red(' ✗ Manifest watcher error:'), event.error?.message); + this.watcher = chokidar.watch(srcPath, { + ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'], + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50, + }, + }); + + this.watcher.on('all', async (event, filePath) => { + if (!filePath.match(/\.(ts|tsx|json)$/)) { + return; + } + + console.log(chalk.gray(` File ${event}: ${path.relative(this.appPath, filePath)}`)); + + const manifest = await runManifestBuild(this.appPath); + + if (manifest) { + printWatchingMessage(); + this.callbacks.onBuildSuccess?.(manifest); } }); @@ -49,68 +55,6 @@ export class ManifestWatcher implements RestartableWatcher { } async close(): Promise { - await this.innerWatcher?.close(); - const tmpDir = path.join(this.appPath, OUTPUT_DIR, 'manifest-watcher-tmp'); - await fs.remove(tmpDir); - } - - private createManifestBuildPlugin(): Plugin { - let isFirstBuild = true; - - return { - name: 'manifest-build-plugin', - writeBundle: async () => { - if (isFirstBuild) { - isFirstBuild = false; - return; - } - - const manifest = await runManifestBuild(this.appPath); - - if (manifest) { - printWatchingMessage(); - this.callbacks.onBuildSuccess?.(manifest); - } - }, - }; - } - - private createConfig(): InlineConfig { - const outputDir = path.join(this.appPath, OUTPUT_DIR, 'manifest-watcher-tmp'); - const entryPath = path.join(this.appPath, 'src/app/application.config.ts'); - - return { - root: this.appPath, - plugins: [ - tsconfigPaths({ root: this.appPath }), - this.createManifestBuildPlugin(), - ], - build: { - outDir: outputDir, - emptyOutDir: true, - watch: { - include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], - exclude: ['node_modules/**', '.twenty/**', 'dist/**'], - }, - lib: { - entry: { __manifest_watch__: entryPath }, - formats: ['es'], - fileName: () => '__manifest_watch__.js', - }, - rollupOptions: { - external: (id) => { - if (id === entryPath || id.endsWith('application.config.ts')) { - return false; - } - return true; - }, - treeshake: false, - }, - minify: false, - sourcemap: false, - }, - logLevel: 'silent', - configFile: false, - }; + await this.watcher?.close(); } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/vite-module-loader.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/vite-module-loader.ts deleted file mode 100644 index e23fd1d34f..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/vite-module-loader.ts +++ /dev/null @@ -1,111 +0,0 @@ -import path from 'path'; -import { createServer, type ViteDevServer } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; - -// Singleton Vite dev server per appPath -const servers = new Map(); - -export const getViteServer = async (appPath: string): Promise => { - const existing = servers.get(appPath); - if (existing) { - return existing; - } - - const server = await createServer({ - root: appPath, - plugins: [tsconfigPaths({ root: appPath })], - server: { middlewareMode: true }, - optimizeDeps: { disabled: true }, - logLevel: 'silent', - configFile: false, - esbuild: { - jsx: 'automatic', - }, - }); - - servers.set(appPath, server); - return server; -}; - -export const closeViteServer = async (appPath?: string): Promise => { - if (appPath) { - const server = servers.get(appPath); - if (server) { - await server.close(); - servers.delete(appPath); - } - } else { - // Close all servers - for (const [key, server] of servers) { - await server.close(); - servers.delete(key); - } - } -}; - -// Load a module using Vite's SSR loader -export const loadModule = async ( - server: ViteDevServer, - filepath: string, -): Promise> => { - return (await server.ssrLoadModule(filepath)) as Record; -}; - -// Find where an identifier was imported from by parsing the source -// and using Vite's module graph to resolve the import path -export const findImportSource = async ( - server: ViteDevServer, - filepath: string, - identifier: string, - appPath: string, -): Promise => { - // Read the source and find import statements - const fs = await import('fs-extra'); - const source = await fs.default.readFile(filepath, 'utf8'); - - // Find the import statement that imports the identifier - const importRegexes = [ - // Named import: import { identifier } from 'path' - new RegExp( - `import\\s*\\{[^}]*\\b${identifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, - ), - // Aliased import: import { something as identifier } from 'path' - new RegExp( - `import\\s*\\{[^}]*\\w+\\s+as\\s+${identifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, - ), - // Default import: import identifier from 'path' - new RegExp(`import\\s+${identifier}\\s+from\\s*['"]([^'"]+)['"]`), - ]; - - let importSpecifier: string | null = null; - for (const regex of importRegexes) { - const match = source.match(regex); - if (match) { - importSpecifier = match[1]; - break; - } - } - - if (!importSpecifier) { - // Not imported, must be defined in the same file - return null; - } - - // Use Vite to resolve the import path - const resolved = await server.pluginContainer.resolveId(importSpecifier, filepath); - if (resolved?.id) { - return path.relative(appPath, resolved.id).replace(/\\/g, '/'); - } - - // Fallback to simple relative path resolution - if (importSpecifier.startsWith('.')) { - const fileDir = path.dirname(filepath); - const absolutePath = path.resolve(fileDir, importSpecifier); - const relativePath = path.relative(appPath, absolutePath); - return ( - relativePath.endsWith('.ts') ? relativePath : `${relativePath}.ts` - ).replace(/\\/g, '/'); - } - - return null; -}; diff --git a/yarn.lock b/yarn.lock index becc872a87..6ea82ab3ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23686,15 +23686,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash.capitalize@npm:^4": - version: 4.2.9 - resolution: "@types/lodash.capitalize@npm:4.2.9" - dependencies: - "@types/lodash": "npm:*" - checksum: 10c0/4a4bc23bc82a8a0952bf75712cea34cd9e6eb15ef77a58352d19f387be50cdea0b6f2b21e7f0d87c1623bfd42b8d4fd2384478901702b146f016f4c7c11e1abb - languageName: node - linkType: hard - "@types/lodash.chunk@npm:^4.2.9": version: 4.2.9 resolution: "@types/lodash.chunk@npm:4.2.9" @@ -29687,7 +29678,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:4.0.3, chokidar@npm:^4.0.1, chokidar@npm:^4.0.3": +"chokidar@npm:4.0.3, chokidar@npm:^4.0.0, chokidar@npm:^4.0.1, chokidar@npm:^4.0.3": version: 4.0.3 resolution: "chokidar@npm:4.0.3" dependencies: @@ -42988,13 +42979,6 @@ __metadata: languageName: node linkType: hard -"lodash.capitalize@npm:^4.2.1": - version: 4.2.1 - resolution: "lodash.capitalize@npm:4.2.1" - checksum: 10c0/b289326497c2e24d6b8afa2af2ca4e068ef6ef007ade36bfb6f70af77ce10ea3f090eeee947d5fdcf2db4bcfa4703c8c10a5857a2b39e308bddfd1d11ad35970 - languageName: node - linkType: hard - "lodash.chunk@npm:4.2.0, lodash.chunk@npm:^4.2.0": version: 4.2.0 resolution: "lodash.chunk@npm:4.2.0" @@ -56983,14 +56967,13 @@ __metadata: "@types/fs-extra": "npm:^11.0.0" "@types/inquirer": "npm:^9.0.0" "@types/lodash.camelcase": "npm:^4.3.7" - "@types/lodash.capitalize": "npm:^4" "@types/lodash.kebabcase": "npm:^4.1.7" - "@types/lodash.startcase": "npm:^4" "@types/node": "npm:^24.0.0" "@types/react": "npm:^19.0.2" archiver: "npm:^7.0.1" axios: "npm:^1.6.0" chalk: "npm:^5.3.0" + chokidar: "npm:^4.0.0" commander: "npm:^12.0.0" dotenv: "npm:^16.4.0" fast-glob: "npm:^3.3.0" @@ -57000,9 +56983,7 @@ __metadata: inquirer: "npm:^10.0.0" jsonc-parser: "npm:^3.2.0" lodash.camelcase: "npm:^4.3.0" - lodash.capitalize: "npm:^4.2.1" lodash.kebabcase: "npm:^4.1.1" - lodash.startcase: "npm:^4.4.0" tsx: "npm:^4.7.0" typescript: "npm:^5.9.2" uuid: "npm:^13.0.0"