diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-extract-config-from-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-extract-config-from-file.spec.ts new file mode 100644 index 0000000000..641cdd21a8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-extract-config-from-file.spec.ts @@ -0,0 +1,43 @@ +import { createRequire } from 'module'; +import { join } from 'path'; + +import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; +import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file'; +import { type ApplicationConfig } from '@/sdk/define'; + +describe('extractManifestFromFile', () => { + const filePath = join(MINIMAL_APP_PATH, 'application.config.ts'); + + it('extracts the default-exported config from a bundled entity file', async () => { + const result = await extractManifestFromFile({ + filePath, + appPath: MINIMAL_APP_PATH, + }); + + expect(result.config.displayName).toBe('Root App'); + }, 60000); + + // Regression test for the dev-mode OOM crash: bundled entity modules used to be + // written to disk and required, leaking one fully-bundled module per file into + // the require cache on every rebuild. Evaluating in memory keeps it bounded. + it('does not grow the require cache across rebuilds', async () => { + const requireCache = createRequire(import.meta.url).cache; + + // Warm up so first-time dependency loads don't count against the assertion. + await extractManifestFromFile({ + filePath, + appPath: MINIMAL_APP_PATH, + }); + + const before = Object.keys(requireCache).length; + + for (let index = 0; index < 5; index++) { + await extractManifestFromFile({ + filePath, + appPath: MINIMAL_APP_PATH, + }); + } + + expect(Object.keys(requireCache).length).toBe(before); + }, 60000); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts index dd26ddc102..bf547162fc 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts @@ -1,13 +1,20 @@ import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin'; -import { pathExists, remove } from '@/cli/utilities/file/fs-utils'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; import { type ValidationResult } from '@/sdk/define'; import * as esbuild from 'esbuild'; import { createRequire } from 'module'; -import { mkdtemp, writeFile } from 'node:fs/promises'; -import os from 'os'; +import vm from 'node:vm'; import path from 'path'; import { isDefined, isPlainObject } from 'twenty-shared/utils'; +type CompiledModuleWrapper = ( + exports: Record, + require: NodeRequire, + module: { exports: Record }, + filename: string, + dirname: string, +) => void; + const MANIFEST_MOCK_MODULES = [ 'twenty-sdk/ui', 'twenty-client-sdk/core', @@ -86,16 +93,23 @@ const loadModule = async ({ const code = result.outputFiles[0].text; - const tempDir = await mkdtemp(path.join(os.tmpdir(), 'twenty-manifest-')); - const tempFile = path.join(tempDir, 'module.cjs'); + const compiledWrapper = vm.compileFunction( + code, + ['exports', 'require', 'module', '__filename', '__dirname'], + { filename: filePath }, + ) as unknown as CompiledModuleWrapper; - try { - await writeFile(tempFile, code); + const moduleShim: { exports: Record } = { exports: {} }; - return appRequire(tempFile) as Record; - } finally { - await remove(tempDir); - } + compiledWrapper( + moduleShim.exports, + appRequire, + moduleShim, + filePath, + path.dirname(filePath), + ); + + return moduleShim.exports; }; const extractDefaultConfigFromModuleOrThrow = (