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