Rework SDK watcher (#17305)

This commit is contained in:
Charles Bochet
2026-01-21 16:18:54 +01:00
committed by GitHub
parent 54a9a1087b
commit 5ee6853d7e
20 changed files with 264 additions and 427 deletions
+1 -4
View File
@@ -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",
@@ -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)));
@@ -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');
};
@@ -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<string, string> => {
const entries: Record<string, string> = {};
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<string, string> => {
const entries: Record<string, string> = {};
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<string, string>;
@@ -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: {
@@ -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);
});
});
@@ -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');
};
@@ -20,7 +20,7 @@ const buildFunctionEntries = (
const entries: Record<string, string> = {};
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: {
@@ -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<Application>
{
async build(appPath: string): Promise<Application> {
const applicationConfigPath = path.join(
appPath,
'src',
'app',
'application.config.ts',
);
const applicationConfigPath = path.join(appPath, 'src', 'app', 'application.config.ts');
return extractManifestFromFile<Application>(applicationConfigPath, appPath);
return manifestExtractFromFileServer.extractManifestFromFile<Application>(applicationConfigPath);
}
validate(application: Application, errors: ValidationError[]): void {
@@ -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<FrontComponentManifest>(
await manifestExtractFromFileServer.extractManifestFromFile<FrontComponentManifest>(
filepath,
appPath,
{ entryProperty: 'component', jsx: true },
{ entryProperty: 'component' },
),
);
} catch (error) {
@@ -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<ServerlessFunctionManifest>(
await manifestExtractFromFileServer.extractManifestFromFile<ServerlessFunctionManifest>(
filepath,
appPath,
{ entryProperty: 'handler' },
),
);
@@ -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<ObjectExtensionManifest>(
filepath,
appPath,
),
await manifestExtractFromFileServer.extractManifestFromFile<ObjectExtensionManifest>(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,
@@ -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<ObjectManifest>(filepath, appPath),
await manifestExtractFromFileServer.extractManifestFromFile<ObjectManifest>(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,
@@ -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<RoleManifest[]>
for (const filepath of roleFiles) {
try {
roleManifests.push(
await extractManifestFromFile<RoleManifest>(filepath, appPath),
await manifestExtractFromFileServer.extractManifestFromFile<RoleManifest>(filepath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
@@ -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'),
@@ -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<TManifest>(
filepath: string,
options: ExtractManifestOptions = {},
): Promise<TManifest> {
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<string, unknown>;
const config = this.extractConfigFromModule<Record<string, unknown>>(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<void> {
if (this.server) {
await this.server.close();
this.server = null;
}
}
private async getServer(): Promise<ViteDevServer> {
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<T>(
module: Record<string, unknown>,
entryProperty?: string,
): T | undefined {
const hasValidEntry = (value: unknown): boolean =>
isPlainObject(value) &&
typeof (value as Record<string, unknown>)[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<string | null> {
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();
@@ -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 = <T>(
module: Record<string, unknown>,
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 <TManifest>(
filepath: string,
appPath: string,
options: ExtractManifestOptions = {},
): Promise<TManifest> => {
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<string, unknown>)[entryProperty] === 'function'
: undefined;
const config = findConfigInModule<Record<string, unknown>>(
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 };
@@ -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/',
});
@@ -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<void> {
throw new Error('Method not implemented.');
}
shouldRestart(_oldManifest: ApplicationManifest | null, _newManifest: ApplicationManifest): boolean {
throw new Error('Method not implemented.');
}
async start(): Promise<void> {
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<void> {
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();
}
}
@@ -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<string, ViteDevServer>();
export const getViteServer = async (appPath: string): Promise<ViteDevServer> => {
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<void> => {
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<Record<string, unknown>> => {
return (await server.ssrLoadModule(filepath)) as Record<string, unknown>;
};
// 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<string | null> => {
// 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;
};
+2 -21
View File
@@ -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"