Compare commits

...
Author SHA1 Message Date
Charles Bochet 628fda5f0e feat(twenty-sdk): stub twenty-sdk/define for app bundles to drop zod + locales
The twenty CLI's esbuild only externalizes `twenty-client-sdk/{core,metadata}`
when bundling user logic functions and front components. Everything else is
inlined, including `twenty-sdk/define` — which transitively imports `zod` (and
all ~80 locales) and `@sniptt/guards`. Result: a 30-line `defineLogicFunction`
handler compiles to ~1.25 MB of .mjs + ~3.3 MB of .map per file.

`twenty-sdk/define` is an authoring surface: its `defineXxx` helpers only run
build-time validation, and manifest extraction already imports the real module
directly (via `manifest-extract-config-from-file.ts`, unchanged). At runtime,
the server just reads `default.config.handler` / `default.config.component` —
the validation result wrapper and zod schemas are dead weight.

This change adds a zero-dependency runtime stub that passes the config through
untouched, then aliases `twenty-sdk/define` → `twenty-sdk/define-runtime-stub`
in the app-bundle esbuild configs only. Build-time manifest extraction still
uses the real, validating module.

Result on a representative app (twenty-eng, 31 logic functions):
- logic-function .mjs: 1.26 MB → 332 KB (-75%)
- .mjs.map: 3.3 MB → 744 KB (-77%)

Front components still ship ~2 MB because `twenty-sdk/front-component` has the
same zod/sniptt pull-through — that's a follow-up with a different shape
(front-component helpers have real runtime behavior that the host injects, so
a blanket stub doesn't apply).
2026-04-24 12:05:30 +02:00
8 changed files with 356 additions and 3 deletions
+8
View File
@@ -32,6 +32,11 @@
"import": "./dist/define/index.mjs",
"require": "./dist/define/index.cjs"
},
"./define-runtime-stub": {
"types": "./dist/define/index.d.ts",
"import": "./dist/define-runtime-stub/index.mjs",
"require": "./dist/define-runtime-stub/index.cjs"
},
"./front-component": {
"types": "./dist/front-component/index.d.ts",
"import": "./dist/front-component/index.mjs",
@@ -108,6 +113,9 @@
"define": [
"dist/define/index.d.ts"
],
"define-runtime-stub": [
"dist/define/index.d.ts"
],
"front-component": [
"dist/front-component/index.d.ts"
],
+4 -3
View File
@@ -14,7 +14,7 @@
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.browser.ts",
"npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.define-stub.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.browser.ts",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist",
"npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs"
],
@@ -26,7 +26,7 @@
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.browser.ts && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist && npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs && npx vite build -c vite.config.node.ts --watch & npx vite build -c vite.config.define.ts --watch & npx vite build -c vite.config.billing.ts --watch & npx vite build -c vite.config.front-component.ts --watch & npx vite build -c vite.config.browser.ts --watch"
"command": "npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.define-stub.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.browser.ts && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist && npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs && npx vite build -c vite.config.node.ts --watch & npx vite build -c vite.config.define.ts --watch & npx vite build -c vite.config.billing.ts --watch & npx vite build -c vite.config.front-component.ts --watch & npx vite build -c vite.config.browser.ts --watch"
}
},
"start": {
@@ -93,11 +93,12 @@
"inputs": ["{projectRoot}/src/sdk/**/*"],
"outputs": [
"{projectRoot}/dist/define",
"{projectRoot}/dist/define-runtime-stub",
"{projectRoot}/dist/front-component"
],
"options": {
"cwd": "{projectRoot}",
"command": "npx vite build -c vite.config.define.ts && npx vite build -c vite.config.front-component.ts && npx rollup -c rollup.config.sdk-dts.mjs"
"command": "npx vite build -c vite.config.define.ts && npx vite build -c vite.config.define-stub.ts && npx vite build -c vite.config.front-component.ts && npx rollup -c rollup.config.sdk-dts.mjs"
}
}
}
@@ -0,0 +1,14 @@
/**
* esbuild aliases applied when building user app code (logic functions
* and front components).
*
* `twenty-sdk/define` is an authoring surface whose `defineXxx` helpers
* only run build-time validation. Its compiled `dist` also transitively
* imports `zod` (+ every locale) and `@sniptt/guards`. Redirecting to
* `twenty-sdk/define-runtime-stub` — which exposes passthrough identity
* versions of the same surface — lets esbuild tree-shake the zod/sniptt
* dependencies out of user bundles.
*/
export const APP_BUILD_ALIASES: Record<string, string> = {
'twenty-sdk/define': 'twenty-sdk/define-runtime-stub',
};
@@ -8,6 +8,7 @@ import {
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { APP_BUILD_ALIASES } from '@/cli/utilities/build/common/app-build-alias';
import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build';
import { LOGIC_FUNCTION_EXTERNAL_MODULES } from '@/cli/utilities/build/common/esbuild-watcher';
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
@@ -73,6 +74,7 @@ export const buildApplication = async (
platform: 'node',
outdir: join(options.appPath, OUTPUT_DIR),
outExtension: { '.js': '.mjs' },
alias: APP_BUILD_ALIASES,
external: LOGIC_FUNCTION_EXTERNAL_MODULES,
tsconfig: join(options.appPath, 'tsconfig.json'),
sourcemap: true,
@@ -95,6 +97,7 @@ export const buildApplication = async (
sourcemap: true,
metafile: true,
logLevel: 'silent',
alias: APP_BUILD_ALIASES,
plugins: [...getFrontComponentBuildPlugins()],
},
onFileBuilt: collectFileBuilt,
@@ -1,3 +1,4 @@
import { APP_BUILD_ALIASES } from '@/cli/utilities/build/common/app-build-alias';
import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files';
import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
@@ -163,6 +164,7 @@ export class EsbuildWatcher implements RestartableWatcher {
platform: this.config.platform,
outdir: outputDir,
outExtension: { '.js': '.mjs' },
alias: APP_BUILD_ALIASES,
external: this.config.externalModules,
tsconfig: path.join(this.appPath, 'tsconfig.json'),
jsx: this.config.jsx,
@@ -0,0 +1,259 @@
/**
* Runtime stub for `twenty-sdk/define`.
*
* `twenty-sdk/define` is an authoring API: its `defineXxx` helpers only run
* build-time validation, and the full module transitively pulls in `zod`
* (including every locale) plus `@sniptt/guards`. In a built logic function
* or front component that only needs `default.config.handler` /
* `default.config.component` at runtime, those extras inflate bundles by
* ~1MB per file.
*
* The CLI aliases `twenty-sdk/define` to this stub when bundling user code,
* so the published SDK continues to do build-time validation while user
* bundles ship only the identity passthroughs and the handful of enum
* constants they actually reference. Tree-shaking removes the rest.
*
* Keep this file free of imports that transitively reach `zod` — i.e. do not
* re-export from the `twenty-shared/types` barrel, which pulls
* `richTextValueSchema` (zod) in along for the ride.
*/
export { STANDARD_OBJECTS as STANDARD_OBJECT } from 'twenty-shared/metadata';
export { STANDARD_OBJECTS as STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
export { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier';
export { getPublicAssetUrl } from '@/sdk/define/get-public-asset-url';
type PassthroughResult<T> = {
success: true;
config: T;
errors: readonly never[];
};
const passthrough = <T>(config: T): PassthroughResult<T> => ({
success: true,
config,
errors: [],
});
export const defineAgent = passthrough;
export const defineApplication = passthrough;
export const defineField = passthrough;
export const defineFrontComponent = passthrough;
export const defineLogicFunction = passthrough;
export const defineNavigationMenuItem = passthrough;
export const defineObject = passthrough;
export const definePageLayout = passthrough;
export const definePageLayoutTab = passthrough;
export const definePostInstallLogicFunction = passthrough;
export const definePreInstallLogicFunction = passthrough;
export const defineRole = passthrough;
export const defineSkill = passthrough;
export const defineView = passthrough;
export const createValidationResult = <T>({
config,
errors = [],
}: {
config: T;
errors?: string[];
}) => ({
success: errors.length === 0,
config,
errors,
});
export const validateFields = (): string[] => [];
// ─── Enum constants ─────────────────────────────────────────────────────────
// Values inlined (rather than re-exported from `twenty-shared/types`) to keep
// this stub free of the zod-using `richTextValueSchema` re-export that lives
// in the same barrel.
export const AggregateOperations = {
MIN: 'MIN',
MAX: 'MAX',
AVG: 'AVG',
SUM: 'SUM',
COUNT: 'COUNT',
COUNT_UNIQUE_VALUES: 'COUNT_UNIQUE_VALUES',
COUNT_EMPTY: 'COUNT_EMPTY',
COUNT_NOT_EMPTY: 'COUNT_NOT_EMPTY',
COUNT_TRUE: 'COUNT_TRUE',
COUNT_FALSE: 'COUNT_FALSE',
PERCENTAGE_EMPTY: 'PERCENTAGE_EMPTY',
PERCENTAGE_NOT_EMPTY: 'PERCENTAGE_NOT_EMPTY',
} as const;
export const DateDisplayFormat = {
RELATIVE: 'RELATIVE',
USER_SETTINGS: 'USER_SETTINGS',
CUSTOM: 'CUSTOM',
} as const;
export const FieldMetadataSettingsOnClickAction = {
COPY: 'COPY',
OPEN_LINK: 'OPEN_LINK',
OPEN_IN_APP: 'OPEN_IN_APP',
} as const;
export const FieldType = {
ACTOR: 'ACTOR',
ADDRESS: 'ADDRESS',
ARRAY: 'ARRAY',
BOOLEAN: 'BOOLEAN',
CURRENCY: 'CURRENCY',
DATE: 'DATE',
DATE_TIME: 'DATE_TIME',
EMAILS: 'EMAILS',
FILES: 'FILES',
FULL_NAME: 'FULL_NAME',
LINKS: 'LINKS',
MORPH_RELATION: 'MORPH_RELATION',
MULTI_SELECT: 'MULTI_SELECT',
NUMBER: 'NUMBER',
NUMERIC: 'NUMERIC',
PHONES: 'PHONES',
POSITION: 'POSITION',
RATING: 'RATING',
RAW_JSON: 'RAW_JSON',
RELATION: 'RELATION',
RICH_TEXT: 'RICH_TEXT',
SELECT: 'SELECT',
TEXT: 'TEXT',
TS_VECTOR: 'TS_VECTOR',
UUID: 'UUID',
} as const;
export const HTTPMethod = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
PATCH: 'PATCH',
DELETE: 'DELETE',
} as const;
export const NavigationMenuItemType = {
VIEW: 'VIEW',
FOLDER: 'FOLDER',
LINK: 'LINK',
OBJECT: 'OBJECT',
RECORD: 'RECORD',
PAGE_LAYOUT: 'PAGE_LAYOUT',
} as const;
export const NumberDataType = {
FLOAT: 'float',
INT: 'int',
BIGINT: 'bigint',
} as const;
export const ObjectRecordGroupByDateGranularity = {
DAY: 'DAY',
MONTH: 'MONTH',
QUARTER: 'QUARTER',
YEAR: 'YEAR',
WEEK: 'WEEK',
DAY_OF_THE_WEEK: 'DAY_OF_THE_WEEK',
MONTH_OF_THE_YEAR: 'MONTH_OF_THE_YEAR',
QUARTER_OF_THE_YEAR: 'QUARTER_OF_THE_YEAR',
NONE: 'NONE',
} as const;
export const OnDeleteAction = {
CASCADE: 'CASCADE',
RESTRICT: 'RESTRICT',
SET_NULL: 'SET_NULL',
NO_ACTION: 'NO_ACTION',
} as const;
export const PageLayoutTabLayoutMode = {
GRID: 'GRID',
VERTICAL_LIST: 'VERTICAL_LIST',
CANVAS: 'CANVAS',
} as const;
export const PermissionFlag = {
API_KEYS_AND_WEBHOOKS: 'API_KEYS_AND_WEBHOOKS',
WORKSPACE: 'WORKSPACE',
WORKSPACE_MEMBERS: 'WORKSPACE_MEMBERS',
ROLES: 'ROLES',
DATA_MODEL: 'DATA_MODEL',
SECURITY: 'SECURITY',
WORKFLOWS: 'WORKFLOWS',
IMPERSONATE: 'IMPERSONATE',
SSO_BYPASS: 'SSO_BYPASS',
APPLICATIONS: 'APPLICATIONS',
MARKETPLACE_APPS: 'MARKETPLACE_APPS',
LAYOUTS: 'LAYOUTS',
BILLING: 'BILLING',
AI_SETTINGS: 'AI_SETTINGS',
AI: 'AI',
VIEWS: 'VIEWS',
UPLOAD_FILE: 'UPLOAD_FILE',
DOWNLOAD_FILE: 'DOWNLOAD_FILE',
SEND_EMAIL_TOOL: 'SEND_EMAIL_TOOL',
HTTP_REQUEST_TOOL: 'HTTP_REQUEST_TOOL',
CODE_INTERPRETER_TOOL: 'CODE_INTERPRETER_TOOL',
IMPORT_CSV: 'IMPORT_CSV',
EXPORT_CSV: 'EXPORT_CSV',
CONNECTED_ACCOUNTS: 'CONNECTED_ACCOUNTS',
PROFILE_INFORMATION: 'PROFILE_INFORMATION',
} as const;
export const RelationType = {
MANY_TO_ONE: 'MANY_TO_ONE',
ONE_TO_MANY: 'ONE_TO_MANY',
} as const;
export const ViewFilterGroupLogicalOperator = {
AND: 'AND',
OR: 'OR',
NOT: 'NOT',
} as const;
export const ViewFilterOperand = {
IS: 'IS',
IS_NOT_NULL: 'IS_NOT_NULL',
IS_NOT: 'IS_NOT',
LESS_THAN_OR_EQUAL: 'LESS_THAN_OR_EQUAL',
GREATER_THAN_OR_EQUAL: 'GREATER_THAN_OR_EQUAL',
IS_BEFORE: 'IS_BEFORE',
IS_AFTER: 'IS_AFTER',
CONTAINS: 'CONTAINS',
DOES_NOT_CONTAIN: 'DOES_NOT_CONTAIN',
IS_EMPTY: 'IS_EMPTY',
IS_NOT_EMPTY: 'IS_NOT_EMPTY',
IS_RELATIVE: 'IS_RELATIVE',
IS_IN_PAST: 'IS_IN_PAST',
IS_IN_FUTURE: 'IS_IN_FUTURE',
IS_TODAY: 'IS_TODAY',
VECTOR_SEARCH: 'VECTOR_SEARCH',
} as const;
export const ViewKey = {
INDEX: 'INDEX',
} as const;
export const ViewOpenRecordIn = {
SIDE_PANEL: 'SIDE_PANEL',
RECORD_PAGE: 'RECORD_PAGE',
} as const;
export const ViewSortDirection = {
ASC: 'ASC',
DESC: 'DESC',
} as const;
export const ViewType = {
TABLE: 'TABLE',
KANBAN: 'KANBAN',
CALENDAR: 'CALENDAR',
FIELDS_WIDGET: 'FIELDS_WIDGET',
TABLE_WIDGET: 'TABLE_WIDGET',
} as const;
export const ViewVisibility = {
WORKSPACE: 'WORKSPACE',
UNLISTED: 'UNLISTED',
} as const;
+1
View File
@@ -26,6 +26,7 @@
"vite.config.node.ts",
"vite.config.browser.ts",
"vite.config.define.ts",
"vite.config.define-stub.ts",
"vite.config.front-component.ts",
"jest.config.mjs"
]
@@ -0,0 +1,65 @@
import path from 'path';
import { type PackageJson } from 'type-fest';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import packageJson from './package.json';
export default defineConfig(() => {
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-sdk-define-stub',
resolve: {
alias: {
'@/': path.resolve(__dirname, 'src') + '/',
},
},
plugins: [
tsconfigPaths({
root: __dirname,
}),
],
build: {
emptyOutDir: false,
outDir: 'dist/define-runtime-stub',
sourcemap: true,
lib: {
entry: 'src/sdk/define/runtime-stub.ts',
name: 'twenty-sdk-define-runtime-stub',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
},
rollupOptions: {
external: (id: string) => {
if (/^node:/.test(id)) {
return true;
}
const builtins = [
'child_process',
'crypto',
'fs',
'fs/promises',
'module',
'os',
'path',
'stream',
'url',
'util',
];
if (builtins.includes(id)) {
return true;
}
const deps = Object.keys(
(packageJson as PackageJson).dependencies || {},
);
return deps.some((dep) => id === dep || id.startsWith(dep + '/'));
},
},
},
logLevel: 'warn' as const,
};
});