## Summary
Logic-function bundles produced by the twenty-sdk CLI were ~1.18 MB even
for a one-line handler. Root cause: the SDK shipped as a single bundled
barrel (`twenty-sdk` → `dist/index.mjs`) that co-mingled server-side
definition factories with the front-component runtime, validation (zod),
and React. With no `\"sideEffects\"` declaration on the SDK package,
esbuild had to assume every module-level statement could have side
effects and refused to drop unused code.
This PR restructures the SDK so consumers' bundlers can tree-shake at
the leaf level:
- **Reorganized SDK source.** All server-side definition factories now
live under `src/sdk/define/` (agents, application, fields,
logic-functions, objects, page-layouts, roles, skills, views,
navigation-menu-items, etc.). All front-component runtime
(components, hooks, host APIs, command primitives) lives under
`src/sdk/front-component/`. The legacy bare `src/sdk/index.ts` is
removed; the bare `twenty-sdk` entry no longer exists.
- **Split the build configs by purpose / runtime env.** Replaced
`vite.config.sdk.ts` with two purpose-specific configs:
- `vite.config.define.ts` — node target, externals from package
`dependencies`, emits to `dist/define/**`
- `vite.config.front-component.ts` — browser/React target, emits to
`dist/front-component/**`
Both use `preserveModules: true` so each leaf ships as its own `.mjs`.
- **\`\"sideEffects\": false\`** on `twenty-sdk` so esbuild can drop
unreferenced re-exports.
- **\`package.json\` exports + \`typesVersions\`** updated: dropped the
bare \`.\` entry, added \`./front-component\`, and pointed \`./define\`
at the new per-module dist layout.
- **Migrated every internal/example/community app** to the new subpath
imports (`twenty-sdk/define`, `twenty-sdk/front-component`,
`twenty-sdk/ui`).
- **Added \`bundle-investigation\` internal app** that reproduces the
bundle bloat and demonstrates the fix.
- Cleaned up dead \`twenty-sdk/dist/sdk/...\` references in the
front-component story builder, the call-recording app, and the SDK
tsconfig.
## Bundle size impact
Measured with esbuild using the same options as the SDK CLI
(\`packages/twenty-apps/internal/bundle-investigation\`):
| Variant | Imports | Before | After |
| ----------------------- |
------------------------------------------------------- | ---------- |
--------- |
| \`01-bare\` | \`defineLogicFunction\` from \`twenty-sdk/define\` |
1177 KB | **1.6 KB** |
| \`02-with-sdk-client\` | + \`CoreApiClient\` from
\`twenty-client-sdk/core\` | 1177 KB | **1.9 KB** |
| \`03-fetch-issues\` | + GitHub GraphQL fetch + JWT signing + 2
mutations | 1181 KB | **5.8 KB** |
| \`05-via-define-subpath\` | same as \`01\`, via the public subpath |
1177 KB | **1.7 KB** |
That's a ~735× reduction on the bare baseline. Knock-on benefits for
Lambda warm + cold starts, S3 upload size, and \`/tmp\` disk usage in
warm containers.
## Test plan
- [x] \`npx nx run twenty-sdk:build\` succeeds
- [x] \`npx nx run twenty-sdk:typecheck\` passes
- [x] \`npx nx run twenty-sdk:test:unit\` passes (31 files / 257 tests)
- [x] \`npx nx run-many -t typecheck
--projects=twenty-front,twenty-server,twenty-front-component-renderer,twenty-sdk,twenty-shared,bundle-investigation\`
passes
- [x] \`node
packages/twenty-apps/internal/bundle-investigation/scripts/build-variants.mjs\`
produces the sizes above
- [ ] CI green
Made with [Cursor](https://cursor.com)
182 lines
4.8 KiB
TypeScript
182 lines
4.8 KiB
TypeScript
import * as esbuild from 'esbuild';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { getFrontComponentBuildPlugins } from 'twenty-sdk/front-component-renderer/build';
|
|
|
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const exampleSourcesDir = path.resolve(
|
|
dirname,
|
|
'../../src/__stories__/example-sources',
|
|
);
|
|
const exampleSourcesBuiltDir = path.resolve(
|
|
dirname,
|
|
'../../src/__stories__/example-sources-built',
|
|
);
|
|
const exampleSourcesBuiltPreactDir = path.resolve(
|
|
dirname,
|
|
'../../src/__stories__/example-sources-built-preact',
|
|
);
|
|
|
|
const rootNodeModules = path.resolve(dirname, '../../../../node_modules');
|
|
|
|
const twentyUiIndividualIndex = path.resolve(
|
|
dirname,
|
|
'../../../twenty-ui/dist/individual/individual-entry.js',
|
|
);
|
|
|
|
const sdkDefineIndex = path.resolve(
|
|
dirname,
|
|
'../../../twenty-sdk/dist/define/index.mjs',
|
|
);
|
|
|
|
const sdkFrontComponentIndex = path.resolve(
|
|
dirname,
|
|
'../../../twenty-sdk/dist/front-component/index.mjs',
|
|
);
|
|
|
|
const twentySharedIndividualDir = path.resolve(
|
|
dirname,
|
|
'../../../twenty-shared/dist/individual',
|
|
);
|
|
|
|
const TWENTY_SHARED_SUBMODULES = [
|
|
'ai',
|
|
'application',
|
|
'constants',
|
|
'database-events',
|
|
'metadata',
|
|
'testing',
|
|
'translations',
|
|
'types',
|
|
'utils',
|
|
'workflow',
|
|
'workspace',
|
|
];
|
|
|
|
const twentySharedAliases = Object.fromEntries(
|
|
TWENTY_SHARED_SUBMODULES.map((submodule) => [
|
|
`twenty-shared/${submodule}`,
|
|
path.join(twentySharedIndividualDir, submodule, 'index.js'),
|
|
]),
|
|
);
|
|
|
|
const storyAlias = {
|
|
react: path.join(rootNodeModules, 'react'),
|
|
'react-dom': path.join(rootNodeModules, 'react-dom'),
|
|
'twenty-sdk/define': sdkDefineIndex,
|
|
'twenty-sdk/front-component': sdkFrontComponentIndex,
|
|
'twenty-sdk/ui': twentyUiIndividualIndex,
|
|
...twentySharedAliases,
|
|
};
|
|
|
|
const STORY_COMPONENTS = [
|
|
'static.front-component',
|
|
'interactive.front-component',
|
|
'lifecycle.front-component',
|
|
'chakra-example.front-component',
|
|
'tailwind-example.front-component',
|
|
'emotion-example.front-component',
|
|
'styled-components-example.front-component',
|
|
'shadcn-example.front-component',
|
|
'mui-example.front-component',
|
|
'twenty-ui-example.front-component',
|
|
'sdk-context-example.front-component',
|
|
'form-events.front-component',
|
|
'keyboard-events.front-component',
|
|
'host-api-calls.front-component',
|
|
];
|
|
|
|
const resolveEntryPoints = (): Record<string, string> => {
|
|
const entryPoints: Record<string, string> = {};
|
|
|
|
for (const name of STORY_COMPONENTS) {
|
|
const filePath = path.join(exampleSourcesDir, `${name}.tsx`);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new Error(
|
|
`Story component source file not found: ${filePath}\n` +
|
|
`Ensure the file exists in ${exampleSourcesDir} and the name in STORY_COMPONENTS is correct.`,
|
|
);
|
|
}
|
|
|
|
entryPoints[name] = filePath;
|
|
}
|
|
|
|
return entryPoints;
|
|
};
|
|
|
|
type BundleSizeEntry = {
|
|
name: string;
|
|
reactBytes: number;
|
|
preactBytes: number;
|
|
};
|
|
|
|
const collectBundleSizes = (): BundleSizeEntry[] =>
|
|
STORY_COMPONENTS.map((name) => {
|
|
const reactFile = path.join(exampleSourcesBuiltDir, `${name}.mjs`);
|
|
const preactFile = path.join(exampleSourcesBuiltPreactDir, `${name}.mjs`);
|
|
|
|
return {
|
|
name,
|
|
reactBytes: fs.existsSync(reactFile) ? fs.statSync(reactFile).size : 0,
|
|
preactBytes: fs.existsSync(preactFile) ? fs.statSync(preactFile).size : 0,
|
|
};
|
|
});
|
|
|
|
const buildSourceExamples = async (): Promise<void> => {
|
|
const entryPoints = resolveEntryPoints();
|
|
const tsconfigPath = path.join(dirname, '../../tsconfig.json');
|
|
|
|
const commonOptions: esbuild.BuildOptions = {
|
|
entryPoints,
|
|
bundle: true,
|
|
splitting: false,
|
|
format: 'esm',
|
|
outExtension: { '.js': '.mjs' },
|
|
tsconfig: tsconfigPath,
|
|
jsx: 'automatic',
|
|
sourcemap: true,
|
|
metafile: true,
|
|
logLevel: 'silent',
|
|
minify: true,
|
|
alias: storyAlias,
|
|
};
|
|
|
|
fs.mkdirSync(exampleSourcesBuiltDir, { recursive: true });
|
|
|
|
await esbuild.build({
|
|
...commonOptions,
|
|
outdir: exampleSourcesBuiltDir,
|
|
plugins: getFrontComponentBuildPlugins(),
|
|
});
|
|
|
|
console.log(
|
|
`Built ${STORY_COMPONENTS.length} React story components to ${exampleSourcesBuiltDir}`,
|
|
);
|
|
|
|
fs.mkdirSync(exampleSourcesBuiltPreactDir, { recursive: true });
|
|
|
|
await esbuild.build({
|
|
...commonOptions,
|
|
outdir: exampleSourcesBuiltPreactDir,
|
|
plugins: getFrontComponentBuildPlugins({ usePreact: true }),
|
|
});
|
|
|
|
console.log(
|
|
`Built ${STORY_COMPONENTS.length} Preact story components to ${exampleSourcesBuiltPreactDir}`,
|
|
);
|
|
|
|
const sizes = collectBundleSizes();
|
|
const manifestPath = path.join(exampleSourcesBuiltDir, 'bundle-sizes.json');
|
|
|
|
fs.writeFileSync(manifestPath, JSON.stringify(sizes, null, 2));
|
|
console.log(`Wrote bundle size manifest to ${manifestPath}`);
|
|
};
|
|
|
|
buildSourceExamples().catch((error) => {
|
|
console.error('Failed to build mock components:', error);
|
|
process.exit(1);
|
|
});
|