From f04db9751f98e8ae0e991e83807000007a789d34 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:19:18 +0200 Subject: [PATCH] fix(client-sdk): bundle metadata client into a single self-contained file (#22085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../postcard/e2e/card-front-component.spec.ts | 10 ++ .../postcard/src/components/card-test-ids.ts | 4 + .../src/components/card.front-component.tsx | 105 +++++++++++++++++- packages/twenty-client-sdk/package.json | 2 +- packages/twenty-client-sdk/project.json | 2 +- packages/twenty-client-sdk/vite.config.ts | 40 +------ .../twenty-client-sdk/vite.metadata.config.ts | 43 +++++++ packages/twenty-client-sdk/vite.shared.ts | 37 ++++++ 8 files changed, 198 insertions(+), 45 deletions(-) create mode 100644 packages/twenty-client-sdk/vite.metadata.config.ts create mode 100644 packages/twenty-client-sdk/vite.shared.ts diff --git a/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts b/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts index 6e3142a2ce..fae5d31d85 100644 --- a/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts +++ b/packages/twenty-apps/examples/postcard/e2e/card-front-component.spec.ts @@ -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'); }); }); diff --git a/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts b/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts index acb5361b43..8c8fac0dbd 100644 --- a/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts +++ b/packages/twenty-apps/examples/postcard/src/components/card-test-ids.ts @@ -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; diff --git a/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx b/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx index b5280470a2..c09d7545aa 100644 --- a/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx +++ b/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx @@ -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 = { + pending: '…', + ok: 'ok', + error: 'error', +}; + +const SdkProbeRow = ({ + testId, + label, + state, +}: { + testId: string; + label: string; + state: SdkProbeState; +}) => ( + + {label}: {SDK_PROBE_LABEL[state]} + +); + +const SdkHealthPanel = () => { + const [coreState, setCoreState] = useState('pending'); + const [metadataState, setMetadataState] = useState('pending'); + const [restState, setRestState] = useState('pending'); + + useEffect(() => { + let cancelled = false; + + const probe = async ( + run: () => Promise, + 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 ( +
+ + + +
+ ); +}; + const PostCardPreview = () => { const recordId = useRecordId(); const [postCard, setPostCard] = useState(null); @@ -184,11 +276,14 @@ const PostCardPreview = () => { } return ( - + <> + + + ); }; diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json index b226929936..f5572e0887 100644 --- a/packages/twenty-client-sdk/package.json +++ b/packages/twenty-client-sdk/package.json @@ -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": { diff --git a/packages/twenty-client-sdk/project.json b/packages/twenty-client-sdk/project.json index 93498eae20..d97d8fe966 100644 --- a/packages/twenty-client-sdk/project.json +++ b/packages/twenty-client-sdk/project.json @@ -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 diff --git a/packages/twenty-client-sdk/vite.config.ts b/packages/twenty-client-sdk/vite.config.ts index b1680c0db4..fb6aaf5c57 100644 --- a/packages/twenty-client-sdk/vite.config.ts +++ b/packages/twenty-client-sdk/vite.config.ts @@ -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, diff --git a/packages/twenty-client-sdk/vite.metadata.config.ts b/packages/twenty-client-sdk/vite.metadata.config.ts new file mode 100644 index 0000000000..75fda15a5a --- /dev/null +++ b/packages/twenty-client-sdk/vite.metadata.config.ts @@ -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', + }; +}); diff --git a/packages/twenty-client-sdk/vite.shared.ts b/packages/twenty-client-sdk/vite.shared.ts new file mode 100644 index 0000000000..3c6c6b24e6 --- /dev/null +++ b/packages/twenty-client-sdk/vite.shared.ts @@ -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}`; +};