Rework SDK watcher (#17305)
This commit is contained in:
@@ -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');
|
||||
};
|
||||
+18
-30
@@ -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: {
|
||||
|
||||
+8
-26
@@ -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'),
|
||||
|
||||
+160
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user