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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user