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:
Charles Bochet
2026-01-19 18:43:40 +01:00
committed by GitHub
parent 042b15a7d1
commit 70582d063e
9 changed files with 602 additions and 164 deletions
@@ -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,
);
}
};