twenty/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx
Charles Bochet eb1ca1b9ec
perf(sdk): split twenty-sdk barrel into per-purpose subpaths to cut logic-function bundle ~700x (#19834)
## 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)
2026-04-18 19:38:34 +02:00

190 lines
4.4 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useRecordId } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
export const CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'88c15ae2-5f87-4a6b-b48f-1974bbe62eb7';
type PostCardRecord = {
name: string;
content: string;
status: string;
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: '#999',
SENT: '#e88c30',
DELIVERED: '#4caf50',
RETURNED: '#e05252',
};
const CardDisplay = ({
name,
content,
status,
}: {
name: string;
content: string;
status: string;
}) => {
return (
<div style={{ padding: '24px', fontFamily: 'sans-serif' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
}}
>
<span style={{ fontSize: '15px', fontWeight: 600, color: '#333' }}>
{name || 'Untitled'}
</span>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '4px',
color: '#fff',
backgroundColor: STATUS_COLORS[status] ?? '#999',
}}
>
{status}
</span>
</div>
<p
style={{
fontSize: '14px',
lineHeight: '1.6',
color: '#555',
margin: 0,
whiteSpace: 'pre-line',
}}
>
{content || 'No content yet...'}
</p>
</div>
);
};
const PostCardPreview = () => {
const recordId = useRecordId();
const [postCard, setPostCard] = useState<PostCardRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPostCard = useCallback(async () => {
if (!isDefined(recordId)) {
setLoading(false);
setError('No record ID');
return;
}
try {
setError(null);
const client = new CoreApiClient();
const result = await client.query({
postCard: {
__args: {
filter: { id: { eq: recordId } },
},
name: true,
content: true,
status: true,
},
});
const record = result?.postCard;
if (!isDefined(record)) {
setError('Record not found');
setPostCard(null);
} else {
setPostCard({
name: record.name ?? '',
content: record.content ?? '',
status: record.status ?? 'DRAFT',
});
}
} catch (fetchError) {
setError(
fetchError instanceof Error ? fetchError.message : String(fetchError),
);
setPostCard(null);
}
setLoading(false);
}, [recordId]);
useEffect(() => {
fetchPostCard();
const interval = setInterval(fetchPostCard, 3000);
return () => {
clearInterval(interval);
setPostCard(null);
setLoading(false);
setError(null);
};
}, [fetchPostCard]);
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#b0a89a',
fontFamily: 'Georgia, serif',
fontSize: '14px',
}}
>
Loading postcard...
</div>
);
}
if (!isDefined(postCard)) {
return (
<div
style={{
padding: '16px',
color: '#b0a89a',
fontFamily: 'sans-serif',
fontSize: '13px',
}}
>
<div>{error ?? 'No postcard data'}</div>
<div style={{ marginTop: '8px', fontSize: '11px', color: '#ccc' }}>
recordId: {recordId ?? 'null'} | apiUrl:{' '}
{process.env.TWENTY_API_URL ? 'set' : 'missing'} | token:{' '}
{process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY
? 'set'
: 'missing'}
</div>
</div>
);
}
return (
<CardDisplay
name={postCard.name}
content={postCard.content}
status={postCard.status}
/>
);
};
export default defineFrontComponent({
universalIdentifier: CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'card-component',
description: 'A component using an external component file',
component: PostCardPreview,
});