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:
@@ -10,4 +10,5 @@ export interface RestartableWatcher {
|
||||
export type RestartableWatcherOptions = {
|
||||
appPath: string;
|
||||
buildResult: ManifestBuildResult | null;
|
||||
watch?: boolean;
|
||||
};
|
||||
|
||||
+32
-9
@@ -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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user