fix(client-sdk): bundle metadata client into a single self-contained file (#22085)

## Problem

A front component that imports `MetadataApiClient` from
`twenty-client-sdk/metadata` crashes at render time:

```
FrontComponent error: Failed to resolve module specifier "./chunk-Dqa2HsxW.mjs".
Invalid relative url or base scheme isn't hierarchical.
```

(hash differs per build). The equivalent component using `CoreApiClient`
from `twenty-client-sdk/core` works fine.

## Root cause

The front-component renderer loads each SDK client as a **single
in-memory blob-URL module** and only rewrites the two bare specifiers it
knows (`twenty-client-sdk/core`, `twenty-client-sdk/metadata`). A
blob-URL module cannot resolve a **relative** `import … from
"./chunk-*.mjs"` (blob URLs aren't hierarchical), and that chunk isn't
served anyway.

Only two entrypoints are externalized by the front-component build
(`FRONT_COMPONENT_EXTERNAL_MODULES`) and thus served as blob modules:
`core` and `metadata`. Everything else (`rest`, `generate`) is bundled
into the component and is unaffected. Of those two:

| client | how `dist/*.mjs` is produced | self-contained? |
|---|---|---|
| **core** | esbuild single-file bundle (`compileGeneratedClient`),
re-run per workspace at server runtime by `replaceCoreClient` |  |
| **metadata** | the shared multi-entry Vite build, which hoists shared
code into a relative `chunk-*.mjs` |  |

The metadata client is built once at package-build time (it is not
workspace-specific) and was shipped straight from the multi-entry Vite
output, keeping the unresolvable relative chunk import.

## Regression trace

This was **not** broken on arrival — it regressed via a transitive
bundler swap:

| Date | Commit | Event |
|---|---|---|
| 2026-05-20 | `a26fe3bb65` | Metadata-client-in-front-components
shipped; `twenty-client-sdk` on **Vite 7 (Rollup)** |
| 2026-06-08 | `d2e7dc0e74` (#21309, *"security: bump vulnerable direct
dependencies"*) | Bumped **Vite 7 → 8**, introducing **Rolldown 1.0.3**
(no rolldown entries in the lockfile before this commit) |

Vite 7 is Rollup-based; Vite 8 uses Rolldown. The breaking artifact is
literally a `\0rolldown/runtime.js` shared chunk — a Rolldown construct
that could not have existed before the bump. So the metadata
front-component path worked from 2026-05-20 until the 2026-06-08
security dependency bump silently changed the bundler and split out the
shared runtime chunk.

## Fix

Build the metadata client as its **own single-entry Vite library**
(`vite.metadata.config.ts`) so its output is a single self-contained
file with no shared chunk. `core` / `rest` / `generate` stay in the main
multi-entry build (`vite.config.ts`); shared config (`isExternal`,
`entryFileNames`) is factored into `vite.shared.ts`. The build pipeline
runs `vite build && vite build -c vite.metadata.config.ts`.

The server picks this up automatically: `SdkClientGenerationService`
ships the pre-built package `dist/` and only regenerates the **core**
client; it never regenerates metadata. No server-side change required.

## Regression guard (e2e)

The postcard example's `card.front-component.tsx` previously used
`CoreApiClient` only, so this metadata-only regression had no e2e
coverage. It now loads and round-trips all three SDK clients (`Core`,
`Metadata`, `Rest`) via an SDK health panel, and the e2e asserts the
blob-served `core` + `metadata` probes reach `ok` — which only happens
if those bundles resolve and function. A future chunk-import regression
in either blob module would crash the component on load and fail the
test.

## Verification

- `npx nx build twenty-client-sdk` succeeds.
- `dist/metadata.mjs` / `dist/metadata.cjs`: **0** `chunk-*` imports,
**0** relative imports; both load and export `MetadataApiClient` +
`MetadataSchema`.
- `dist/metadata/index.d.ts` types still emitted.
- `npx nx typecheck` + `npx nx lint twenty-client-sdk` pass; postcard
app typecheck + lint pass.

## Notes

- `dist/` is not committed (CI builds it); a running server must rebuild
`twenty-client-sdk` for the fix to take effect.
- The e2e was validated statically (typecheck + lint); running it
end-to-end requires a live stack with a seeded postcard record.
This commit is contained in:
Paul Rastoin
2026-06-25 10:19:18 +02:00
committed by GitHub
parent cf91b87892
commit f04db9751f
8 changed files with 198 additions and 45 deletions
@@ -125,5 +125,15 @@ test.describe('Postcard card front component', () => {
for (const fallback of FALLBACK_TEXTS) {
await expect(page.getByText(fallback, { exact: false })).toHaveCount(0);
}
const sdkPanel = page.getByTestId(CARD_TEST_IDS.sdkPanel);
await expect(sdkPanel).toBeVisible();
await expect(page.getByTestId(CARD_TEST_IDS.sdkCore)).toHaveText('core: ok');
await expect(page.getByTestId(CARD_TEST_IDS.sdkMetadata)).toHaveText(
'metadata: ok',
);
await expect(page.getByTestId(CARD_TEST_IDS.sdkRest)).toHaveText('rest: ok');
});
});
@@ -6,4 +6,8 @@ export const CARD_TEST_IDS = {
name: 'postcard-card-name',
status: 'postcard-card-status',
content: 'postcard-card-content',
sdkPanel: 'postcard-sdk-panel',
sdkCore: 'postcard-sdk-core',
sdkMetadata: 'postcard-sdk-metadata',
sdkRest: 'postcard-sdk-rest',
} as const;
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useRecordId } from 'twenty-sdk/front-component';
@@ -81,6 +83,96 @@ const CardDisplay = ({
);
};
type SdkProbeState = 'pending' | 'ok' | 'error';
const SDK_PROBE_LABEL: Record<SdkProbeState, string> = {
pending: '…',
ok: 'ok',
error: 'error',
};
const SdkProbeRow = ({
testId,
label,
state,
}: {
testId: string;
label: string;
state: SdkProbeState;
}) => (
<span
data-testid={testId}
style={{ fontSize: '11px', color: state === 'error' ? '#e05252' : '#888' }}
>
{label}: {SDK_PROBE_LABEL[state]}
</span>
);
const SdkHealthPanel = () => {
const [coreState, setCoreState] = useState<SdkProbeState>('pending');
const [metadataState, setMetadataState] = useState<SdkProbeState>('pending');
const [restState, setRestState] = useState<SdkProbeState>('pending');
useEffect(() => {
let cancelled = false;
const probe = async (
run: () => Promise<unknown>,
setState: (state: SdkProbeState) => void,
) => {
try {
await run();
if (!cancelled) {
setState('ok');
}
} catch {
if (!cancelled) {
setState('error');
}
}
};
probe(() => new CoreApiClient().query({ __typename: true }), setCoreState);
probe(
() => new MetadataApiClient().query({ __typename: true }),
setMetadataState,
);
probe(() => new RestApiClient().get('/rest/postCards'), setRestState);
return () => {
cancelled = true;
};
}, []);
return (
<div
data-testid={CARD_TEST_IDS.sdkPanel}
style={{
display: 'flex',
gap: '12px',
padding: '0 24px 16px',
fontFamily: 'sans-serif',
}}
>
<SdkProbeRow
testId={CARD_TEST_IDS.sdkCore}
label="core"
state={coreState}
/>
<SdkProbeRow
testId={CARD_TEST_IDS.sdkMetadata}
label="metadata"
state={metadataState}
/>
<SdkProbeRow
testId={CARD_TEST_IDS.sdkRest}
label="rest"
state={restState}
/>
</div>
);
};
const PostCardPreview = () => {
const recordId = useRecordId();
const [postCard, setPostCard] = useState<PostCardRecord | null>(null);
@@ -184,11 +276,14 @@ const PostCardPreview = () => {
}
return (
<CardDisplay
name={postCard.name}
content={postCard.content}
status={postCard.status}
/>
<>
<CardDisplay
name={postCard.name}
content={postCard.content}
status={postCard.status}
/>
<SdkHealthPanel />
</>
);
};
+1 -1
View File
@@ -4,7 +4,7 @@
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
"build": "npx rimraf dist && npx vite build && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
"build": "npx rimraf dist && npx vite build && npx vite build -c vite.metadata.config.ts && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
},
"exports": {
"./core": {
+1 -1
View File
@@ -14,7 +14,7 @@
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build",
"npx rimraf dist && npx vite build && npx vite build -c vite.metadata.config.ts",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
],
"parallel": false
+2 -38
View File
@@ -1,50 +1,14 @@
import path from 'path';
import { defineConfig } from 'vite';
import packageJson from './package.json';
import { entryFileNames, isExternal } from './vite.shared';
const entries = [
'src/core/index.ts',
'src/metadata/index.ts',
'src/rest/index.ts',
'src/generate/index.ts',
];
const externalDeps = [
...Object.keys(packageJson.dependencies),
...Object.keys(packageJson.devDependencies).filter(
(dep) => dep !== 'twenty-shared',
),
'node:fs/promises',
'node:fs',
'node:path',
'node:os',
'node:url',
];
const isExternal = (id: string) =>
externalDeps.some((dep) => id === dep || id.startsWith(`${dep}/`));
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occur, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occur, splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
export default defineConfig(() => {
return {
root: __dirname,
@@ -0,0 +1,43 @@
import path from 'path';
import { defineConfig } from 'vite';
import { entryFileNames, isExternal } from './vite.shared';
// Built as its own single-entry library so the metadata entrypoint is a single
// self-contained file, instead of sharing a chunk-*.mjs with the other entries
// in the main multi-entry build (vite.config.ts). emptyOutDir: false so it
// writes alongside that build's output rather than wiping it.
export default defineConfig(() => {
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-client-sdk-metadata',
resolve: {
tsconfigPaths: true,
alias: {
'@/': path.resolve(__dirname, 'src') + '/',
},
},
build: {
emptyOutDir: false,
outDir: 'dist',
lib: { entry: 'src/metadata/index.ts', name: 'twenty-client-sdk' },
rollupOptions: {
external: isExternal,
output: [
{
format: 'es',
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
},
{
format: 'cjs',
interop: 'auto',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});
+37
View File
@@ -0,0 +1,37 @@
import packageJson from './package.json';
const externalDeps = [
...Object.keys(packageJson.dependencies),
...Object.keys(packageJson.devDependencies).filter(
(dep) => dep !== 'twenty-shared',
),
'node:fs/promises',
'node:fs',
'node:path',
'node:os',
'node:url',
];
export const isExternal = (id: string) =>
externalDeps.some((dep) => id === dep || id.startsWith(`${dep}/`));
export const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occur, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occur, splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};