1fe6156cf5
## Problem Fixes #22708. `twenty-sdk` 2.19.0 CLI commands run through the ESM entrypoint (`node dist/cli.mjs dev --once`, `app:uninstall`, ...) crash on startup: ``` Error: Calling `require` for "fs" in an environment that doesn't expose the `require` function. ``` ## Root cause The 2.19.0 release switched bundling from esbuild to **rolldown** (Vite 7 → 8). Rolldown **inlines CommonJS dependencies** into the ESM output (`dist/cli.mjs` grows from ~6k to ~136k lines). Those third-party CJS modules — e.g. `typescript`, pulled in via `ts-morph` — call `require(...)` and read `__filename` / `__dirname` at load time. None of those exist in an ES module: - `require(...)` is routed through rolldown's interop shim, which **throws** when `require` is absent (i.e. in a `.mjs` file). - `__filename` / `__dirname` are simply `ReferenceError: … is not defined in ES module scope`. esbuild (2.18.0) injected these CJS globals for node-targeted ESM output; rolldown does not. Because the offending usage lives in **bundled third-party CJS**, prefixing our own imports could not fix it. ## Fix Add a banner to the **ESM output only** in `vite.config.node.ts` that recreates the CJS globals from `import.meta.url`: ```js import { createRequire as __twentyCreateRequire } from 'node:module'; import { fileURLToPath as __twentyFileURLToPath } from 'node:url'; import { dirname as __twentyDirname } from 'node:path'; const require = __twentyCreateRequire(import.meta.url); const __filename = __twentyFileURLToPath(import.meta.url); const __dirname = __twentyDirname(__filename); ``` This is the same `createRequire` pattern the repo already uses for the `twenty-oxlint-rules` ESM build. The CJS output already provides all three, so the banner is not applied there. This PR also prefixes the SDK CLI's own Node-builtin imports with `node:` (using native ESM imports instead of the interop shim for our own code) — good hygiene and guarded by a unit test, but note the **banner is the actual bug fix**. ## Verification (built and run locally) - Built the node bundle and reproduced the crash on the pre-fix build (`Calling require for "fs"`), then a follow-on `__filename is not defined` once `require` was restored. - With the banner, ran the previously-crashing commands against the built `dist/cli.mjs`: - `--help` → prints usage, exit 0 - `dev --once` → reaches "Checking server… Cannot reach Twenty server" (normal, no local server) - `app:uninstall` → reaches the interactive confirmation prompt - CJS bin (`dist/cli.cjs`) still works. ## Tests - `cli-esm-bundle-startup.integration.spec.ts` — runs the **built** `dist/cli.mjs --help` and asserts no require/ESM-scope crash. Demonstrated **red without the banner, green with it**. It runs in the `sdk-test` job (which builds the SDK before tests); it fails loudly in CI if the artifact is missing and skips locally when unbuilt, so it is never silently green in CI. - `node-builtin-import-protocol.test.ts` — guards the `node:`-prefix hygiene across the CLI source. Note: the existing `sdk-e2e-test` never caught this because it runs the CLI via `tsx` on the TypeScript **source**, which has no rolldown shim — only the bundled `.mjs` reproduces the crash.
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { type PackageJson } from 'type-fest';
|
|
import { defineConfig } from 'vite';
|
|
|
|
import packageJson from './package.json';
|
|
|
|
// Injected at the top of every ESM chunk so bundled CommonJS modules keep
|
|
// working after rolldown inlines them into the `.mjs` output. They call
|
|
// `require(...)` and read `__filename` / `__dirname` at runtime — none of which
|
|
// exist in an ES module — so we recreate them from `import.meta.url`. Without
|
|
// this the CLI crashes on startup ("Calling `require` for \"fs\" ...",
|
|
// "__filename is not defined"). The CJS output already provides all three.
|
|
// Aliased imports avoid colliding with bindings that already exist in a chunk.
|
|
//
|
|
// Note: __filename / __dirname resolve to the emitted chunk's own location
|
|
// (dist/), not each inlined module's original node_modules path — so a bundled
|
|
// dependency that reads __dirname to locate sibling files points at dist/.
|
|
// esbuild's CJS-globals shim had the same limitation, so this is not a
|
|
// regression; our own __dirname usage already targets dist/ intentionally.
|
|
const esmNodeGlobalsBanner = [
|
|
"import { createRequire as __twentyCreateRequire } from 'node:module';",
|
|
"import { fileURLToPath as __twentyFileURLToPath } from 'node:url';",
|
|
"import { dirname as __twentyDirname } from 'node:path';",
|
|
'const require = __twentyCreateRequire(import.meta.url);',
|
|
'const __filename = __twentyFileURLToPath(import.meta.url);',
|
|
'const __dirname = __twentyDirname(__filename);',
|
|
].join('\n');
|
|
|
|
const copyCoverAssetsPlugin = () => ({
|
|
name: 'copy-cover-assets',
|
|
closeBundle() {
|
|
const source = path.resolve(
|
|
__dirname,
|
|
'src/cli/utilities/build/cover/assets/halftone-backdrop.png',
|
|
);
|
|
const destinationDir = path.resolve(__dirname, 'dist/assets');
|
|
|
|
fs.mkdirSync(destinationDir, { recursive: true });
|
|
fs.copyFileSync(source, path.join(destinationDir, 'halftone-backdrop.png'));
|
|
},
|
|
});
|
|
|
|
export default defineConfig(() => {
|
|
return {
|
|
root: __dirname,
|
|
cacheDir: '../../node_modules/.vite/packages/twenty-sdk-node',
|
|
resolve: {
|
|
tsconfigPaths: true,
|
|
alias: {
|
|
'@/': path.resolve(__dirname, 'src') + '/',
|
|
},
|
|
},
|
|
plugins: [copyCoverAssetsPlugin()],
|
|
build: {
|
|
emptyOutDir: false,
|
|
outDir: 'dist',
|
|
lib: {
|
|
entry: {
|
|
cli: 'src/cli/cli.ts',
|
|
operations: 'src/cli/operations/index.ts',
|
|
'front-component-renderer/build':
|
|
'src/front-component-renderer/build/index.ts',
|
|
},
|
|
name: 'twenty-sdk',
|
|
},
|
|
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 + '/'));
|
|
},
|
|
output: [
|
|
{
|
|
format: 'es' as const,
|
|
entryFileNames: '[name].mjs',
|
|
banner: esmNodeGlobalsBanner,
|
|
},
|
|
{
|
|
format: 'cjs' as const,
|
|
esModule: true,
|
|
exports: 'named' as const,
|
|
entryFileNames: '[name].cjs',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
logLevel: 'warn' as const,
|
|
};
|
|
});
|