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 index 641cdd21a8..73d57d53d8 100644 --- 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 @@ -1,5 +1,9 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import vm from 'node:vm'; import { createRequire } from 'module'; import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file'; @@ -41,3 +45,91 @@ describe('extractManifestFromFile', () => { expect(Object.keys(requireCache).length).toBe(before); }, 60000); }); + +// Regression guard for the dev-mode OOM (twenty#2601). The manifest is rebuilt +// on every file change and previously recompiled every entity file through +// vm.compileFunction each time, which V8 never releases. The compiled wrapper +// is now cached per file, so an unchanged file must not be recompiled and a +// changed file must replace (not accumulate) its previous build. Fresh temp +// files are used so the per-file cache is cold regardless of test order. +describe('extractManifestFromFile compiled-module caching', () => { + let tmpDir: string; + + const writeConfigFile = async ( + configFilePath: string, + config: Record, + ): Promise => { + await writeFile( + configFilePath, + `export default ${JSON.stringify(config)};\n`, + 'utf8', + ); + }; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(os.tmpdir(), 'manifest-extract-cache-test-')); + await writeFile( + join(tmpDir, 'package.json'), + '{ "name": "test-app" }', + 'utf8', + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('compiles an unchanged file only once across repeated builds', async () => { + const configFilePath = join(tmpDir, 'unchanged-config.ts'); + const config = { name: 'unchanged' }; + await writeConfigFile(configFilePath, config); + + const compileFunctionSpy = vi.spyOn(vm, 'compileFunction'); + + const firstResult = await extractManifestFromFile({ + filePath: configFilePath, + appPath: tmpDir, + }); + const secondResult = await extractManifestFromFile({ + filePath: configFilePath, + appPath: tmpDir, + }); + const thirdResult = await extractManifestFromFile({ + filePath: configFilePath, + appPath: tmpDir, + }); + + // Three rebuilds, but identical output is compiled only once. + expect(compileFunctionSpy).toHaveBeenCalledTimes(1); + + // Behaviour is unchanged: every call still returns the extracted config. + expect(firstResult).toEqual(config); + expect(secondResult).toEqual(config); + expect(thirdResult).toEqual(config); + }, 60000); + + it('recompiles when the file output changes', async () => { + const configFilePath = join(tmpDir, 'changing-config.ts'); + await writeConfigFile(configFilePath, { name: 'before' }); + + const compileFunctionSpy = vi.spyOn(vm, 'compileFunction'); + + const beforeResult = await extractManifestFromFile({ + filePath: configFilePath, + appPath: tmpDir, + }); + + await writeConfigFile(configFilePath, { name: 'after' }); + + const afterResult = await extractManifestFromFile({ + filePath: configFilePath, + appPath: tmpDir, + }); + + // One compile per distinct output, and the new content is returned. + expect(compileFunctionSpy).toHaveBeenCalledTimes(2); + expect(beforeResult).toEqual({ name: 'before' }); + expect(afterResult).toEqual({ name: 'after' }); + }, 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 26477ec354..3e3a8c96a2 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,6 +1,7 @@ import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin'; import { pathExists } from '@/cli/utilities/file/fs-utils'; import { type ValidationResult } from '@/sdk/define'; +import { createHash } from 'node:crypto'; import * as esbuild from 'esbuild'; import { createRequire } from 'module'; import vm from 'node:vm'; @@ -15,6 +16,50 @@ type CompiledModuleWrapper = ( dirname: string, ) => void; +type CachedCompiledModule = { + outputHash: string; + wrapper: CompiledModuleWrapper; +}; + +// vm.compileFunction pins every function it compiles at the V8 isolate level +// and never releases it (nodejs/node#35375). In dev mode the manifest is +// rebuilt on every file change and recompiles every entity file, so compiling +// the same file over and over grows the heap without bound and eventually +// OOM-crashes the process. +// +// We keep only the latest build per file, keyed by file path: when a file is +// rebuilt with unchanged output we reuse its wrapper, and when its output +// changes we overwrite the entry so the previous build is dropped instead of +// accumulating. This bounds the cache to one entry per file rather than one +// per rebuild. +const compiledModuleCacheByFilePath = new Map(); + +const getCompiledWrapper = ( + code: string, + filePath: string, +): CompiledModuleWrapper => { + const outputHash = createHash('sha1').update(code).digest('hex'); + + const cachedModule = compiledModuleCacheByFilePath.get(filePath); + + if (isDefined(cachedModule) && cachedModule.outputHash === outputHash) { + return cachedModule.wrapper; + } + + const compiledWrapper = vm.compileFunction( + code, + ['exports', 'require', 'module', '__filename', '__dirname'], + { filename: filePath }, + ) as unknown as CompiledModuleWrapper; + + compiledModuleCacheByFilePath.set(filePath, { + outputHash, + wrapper: compiledWrapper, + }); + + return compiledWrapper; +}; + const MANIFEST_MOCK_MODULES = [ 'twenty-ui', 'twenty-client-sdk/core', @@ -102,11 +147,7 @@ const loadModule = async ({ const code = result.outputFiles[0].text; - const compiledWrapper = vm.compileFunction( - code, - ['exports', 'require', 'module', '__filename', '__dirname'], - { filename: filePath }, - ) as unknown as CompiledModuleWrapper; + const compiledWrapper = getCompiledWrapper(code, filePath); const moduleShim: { exports: Record } = { exports: {} };