Add sdk build (#17335)

## Summary

Adds `app:build` command as a one-shot version of `app:dev` - builds
manifest, functions, and front components once then exits (no watching).

**Changes:**
- Added `watch` option to `FunctionsWatcher` and
`FrontComponentsWatcher` to support both watch and one-shot modes
- Created `AppBuildCommand` reusing the same build logic as `app:dev`
with `watch: false`
- Added integration tests for `app:build` on both `rich-app` and
`root-app`
- Updated manifest types: `handlerPath` → `sourceHandlerPath` +
`builtHandlerPath`, `componentPath` → `sourceComponentPath` +
`builtComponentPath`
- Updated test app `package.json` scripts to match `create-twenty-app`
template
This commit is contained in:
Charles Bochet
2026-01-22 14:36:40 +01:00
committed by GitHub
parent d6f088f720
commit d537d941a7
25 changed files with 1050 additions and 86 deletions
@@ -10,4 +10,5 @@ export interface RestartableWatcher {
export type RestartableWatcherOptions = {
appPath: string;
buildResult: ManifestBuildResult | null;
watch?: boolean;
};
@@ -29,10 +29,13 @@ export class FrontComponentsWatcher implements RestartableWatcher {
private componentPaths: string[];
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private watchMode: boolean;
private lastInputsSignature: string | null = null;
constructor(options: RestartableWatcherOptions) {
this.appPath = options.appPath;
this.componentPaths = options.buildResult?.filePaths.frontComponents ?? [];
this.watchMode = options.watch ?? true;
}
shouldRestart(result: ManifestBuildResult): boolean {
@@ -51,7 +54,9 @@ export class FrontComponentsWatcher implements RestartableWatcher {
await this.createContext();
} else {
logger.log('No front components to build');
logger.log('👀 Watching for changes...');
if (this.watchMode) {
logger.log('👀 Watching for changes...');
}
}
}
@@ -96,6 +101,11 @@ export class FrontComponentsWatcher implements RestartableWatcher {
entryPoints[entryName] = path.join(this.appPath, componentPath);
}
const watchMode = this.watchMode;
// Capture reference for use in plugin callbacks
const watcher = this;
this.esBuildContext = await esbuild.context({
entryPoints,
bundle: true,
@@ -119,14 +129,25 @@ export class FrontComponentsWatcher implements RestartableWatcher {
for (const error of result.errors) {
logger.error(` ${error.text}`);
}
} else {
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
return;
}
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
const inputs = Object.keys(result.metafile?.inputs ?? {}).sort();
const inputsSignature = inputs.join(',');
if (watcher.lastInputsSignature === inputsSignature) {
return;
}
watcher.lastInputsSignature = inputsSignature;
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
if (watchMode) {
logger.log('👀 Watching for changes...');
}
});
@@ -137,6 +158,8 @@ export class FrontComponentsWatcher implements RestartableWatcher {
await this.esBuildContext.rebuild();
await this.esBuildContext.watch();
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
@@ -43,10 +43,13 @@ export class FunctionsWatcher implements RestartableWatcher {
private functionPaths: string[];
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private watchMode: boolean;
private lastInputsSignature: string | null = null;
constructor(options: RestartableWatcherOptions) {
this.appPath = options.appPath;
this.functionPaths = options.buildResult?.filePaths.functions ?? [];
this.watchMode = options.watch ?? true;
}
shouldRestart(result: ManifestBuildResult): boolean {
@@ -65,7 +68,9 @@ export class FunctionsWatcher implements RestartableWatcher {
await this.createContext();
} else {
logger.log('No functions to build');
logger.log('👀 Watching for changes...');
if (this.watchMode) {
logger.log('👀 Watching for changes...');
}
}
}
@@ -110,6 +115,11 @@ export class FunctionsWatcher implements RestartableWatcher {
entryPoints[entryName] = path.join(this.appPath, functionPath);
}
const watchMode = this.watchMode;
// Capture reference for use in plugin callbacks
const watcher = this;
this.esBuildContext = await esbuild.context({
entryPoints,
bundle: true,
@@ -143,14 +153,25 @@ export class FunctionsWatcher implements RestartableWatcher {
for (const error of result.errors) {
logger.error(` ${error.text}`);
}
} else {
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
return;
}
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
const inputs = Object.keys(result.metafile?.inputs ?? {}).sort();
const inputsSignature = inputs.join(',');
if (watcher.lastInputsSignature === inputsSignature) {
return;
}
watcher.lastInputsSignature = inputsSignature;
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
if (watchMode) {
logger.log('👀 Watching for changes...');
}
});
@@ -161,6 +182,8 @@ export class FunctionsWatcher implements RestartableWatcher {
await this.esBuildContext.rebuild();
await this.esBuildContext.watch();
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
@@ -1,6 +1,7 @@
import { glob } from 'fast-glob';
import { type FrontComponentManifest } from 'twenty-shared/application';
import { createLogger } from '../../common/logger';
import { FRONT_COMPONENTS_DIR } from '../../front-components/constants';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import { type ValidationError } from '../manifest.types';
import {
@@ -12,7 +13,10 @@ import {
const logger = createLogger('manifest-watch');
type FrontComponentConfig = Omit<FrontComponentManifest, 'componentPath' | 'componentName'> & {
type FrontComponentConfig = Omit<
FrontComponentManifest,
'sourceComponentPath' | 'builtComponentPath' | 'componentName'
> & {
component: { name: string };
};
@@ -36,11 +40,13 @@ export class FrontComponentEntityBuilder
);
const { component, ...rest } = config;
const builtComponentPath = this.computeBuiltComponentPath(filePath);
manifests.push({
...rest,
componentName: component.name,
componentPath: filePath,
sourceComponentPath: filePath,
builtComponentPath,
});
} catch (error) {
throw new Error(
@@ -52,6 +58,12 @@ export class FrontComponentEntityBuilder
return { manifests, filePaths: componentFiles };
}
private computeBuiltComponentPath(sourceComponentPath: string): string {
const builtPath = sourceComponentPath.replace(/\.tsx?$/, '.mjs');
return `${FRONT_COMPONENTS_DIR}/${builtPath}`;
}
validate(
components: FrontComponentManifest[],
errors: ValidationError[],
@@ -75,7 +87,7 @@ export class FrontComponentEntityBuilder
logger.log('📍 Entry points:');
for (const component of components) {
const name = component.name || component.universalIdentifier;
logger.log(` - ${name} (${component.componentPath})`);
logger.log(` - ${name} (${component.sourceComponentPath})`);
}
}
}
@@ -1,6 +1,7 @@
import { glob } from 'fast-glob';
import { type ServerlessFunctionManifest } from 'twenty-shared/application';
import { createLogger } from '../../common/logger';
import { FUNCTIONS_DIR } from '../../functions/constants';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import { type ValidationError } from '../manifest.types';
import {
@@ -12,6 +13,13 @@ import {
const logger = createLogger('manifest-watch');
type ExtractedFunctionManifest = Omit<
ServerlessFunctionManifest,
'sourceHandlerPath' | 'builtHandlerPath'
> & {
handlerPath: string;
};
export class FunctionEntityBuilder
implements ManifestEntityBuilder<ServerlessFunctionManifest>
{
@@ -27,12 +35,20 @@ export class FunctionEntityBuilder
try {
const absolutePath = `${appPath}/${filePath}`;
manifests.push(
await manifestExtractFromFileServer.extractManifestFromFile<ServerlessFunctionManifest>(
const extracted =
await manifestExtractFromFileServer.extractManifestFromFile<ExtractedFunctionManifest>(
absolutePath,
{ entryProperty: 'handler' },
),
);
);
const { handlerPath, ...rest } = extracted;
const builtHandlerPath = this.computeBuiltHandlerPath(handlerPath);
manifests.push({
...rest,
sourceHandlerPath: handlerPath,
builtHandlerPath,
});
} catch (error) {
throw new Error(
`Failed to load function from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
@@ -43,6 +59,12 @@ export class FunctionEntityBuilder
return { manifests, filePaths: functionFiles };
}
private computeBuiltHandlerPath(sourceHandlerPath: string): string {
const builtPath = sourceHandlerPath.replace(/\.tsx?$/, '.mjs');
return `${FUNCTIONS_DIR}/${builtPath}`;
}
validate(
functions: ServerlessFunctionManifest[],
errors: ValidationError[],
@@ -120,7 +142,7 @@ export class FunctionEntityBuilder
logger.log('📍 Entry points:');
for (const fn of functions) {
const name = fn.name || fn.universalIdentifier;
logger.log(` - ${name} (${fn.handlerPath})`);
logger.log(` - ${name} (${fn.sourceHandlerPath})`);
}
}
}