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.
This commit is contained in:
martmull
2026-07-10 09:59:48 +02:00
committed by GitHub
parent d430e38a9a
commit 1fe6156cf5
2 changed files with 59 additions and 0 deletions
@@ -0,0 +1,36 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const currentDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(currentDir, '../../../../..');
const bundledCliEntry = resolve(packageRoot, 'dist/cli.mjs');
const isBundleBuilt = existsSync(bundledCliEntry);
describe('CLI ESM bundle startup', () => {
const runOrSkip = isBundleBuilt || process.env.CI ? it : it.skip;
runOrSkip(
'runs `--help` from the bundled .mjs entry without a CJS-in-ESM crash',
() => {
if (!isBundleBuilt) {
throw new Error(
`Expected the built ESM entry at ${bundledCliEntry}. Run \`npx nx build twenty-sdk\` before this test.`,
);
}
const result = spawnSync('node', [bundledCliEntry, '--help'], {
encoding: 'utf8',
timeout: 30_000,
});
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
expect(combinedOutput).not.toMatch(/Calling `require` for/);
expect(combinedOutput).not.toMatch(/is not defined in ES module scope/);
expect(result.status).toBe(0);
expect(result.stdout).toContain('Usage: twenty');
},
);
});
+23
View File
@@ -5,6 +5,28 @@ 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() {
@@ -75,6 +97,7 @@ export default defineConfig(() => {
{
format: 'es' as const,
entryFileNames: '[name].mjs',
banner: esmNodeGlobalsBanner,
},
{
format: 'cjs' as const,