Files
twenty/packages/twenty-sdk/vite.config.node.ts
T
martmull 1fe6156cf5 fix(twenty-sdk): restore require/__filename/__dirname in rolldown ESM CLI bundle (#22711)
## 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.
2026-07-10 09:59:48 +02:00

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,
};
});