diff --git a/packages/devtools/infrastructure/README.md b/packages/devtools/infrastructure/README.md index bfd49d2f9..cabce55a5 100644 --- a/packages/devtools/infrastructure/README.md +++ b/packages/devtools/infrastructure/README.md @@ -285,6 +285,24 @@ const appDefinition = { > â„šī¸ When `usePrismaLambdaLayer: false`, Prisma stays inside each bundle and the runtime automatically loads the correct binary. No extra configuration is required. +**Keeping nested node_modules in Lambda packages:** + +By default every function package excludes `node_modules/**/node_modules/**`. npm only nests a package when the root copy cannot satisfy a dependant, so the exclusion makes a package resolve whatever version is hoisted, and it removes the package entirely when the root copy is a dev dependency. The first `require` of such a package then fails at Lambda init with `Runtime.ImportModuleError`. + +To ship the dependency tree the way npm resolved it, opt in per app: + +```javascript +const appDefinition = { + name: 'my-app', + lambda: { + keepNestedNodeModules: true, + }, + integrations: [{ Definition: { name: 'salesforce' } }], +}; +``` + +Nested copies of `@friggframework/*`, `aws-sdk`, `@aws-sdk/*` and, when the Prisma layer is enabled, `@prisma/*` and `.prisma` stay excluded: the app's single core, the Lambda runtime and the layer provide them. Expect each function package to grow by the size of the nested directories that remain (a few MB compressed in a typical app). The default stays unchanged for apps that do not set the flag. + ## Usage Examples ### Basic Deployment diff --git a/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js index e5e0d2860..50fb0346b 100644 --- a/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js +++ b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js @@ -15,6 +15,7 @@ const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder'); const { isScopedEnvironmentActive } = require('../shared/function-environments'); +const { nestedNodeModulesExcludes } = require('../shared/utilities/nested-node-modules'); class AdminScriptBuilder extends InfrastructureBuilder { constructor() { @@ -407,7 +408,7 @@ class AdminScriptBuilder extends InfrastructureBuilder { 'node_modules/@friggframework/core/generated/**', ] : []), - 'node_modules/**/node_modules/**', + ...nestedNodeModulesExcludes(appDefinition, usePrismaLayer), 'node_modules/@friggframework/test/**', 'node_modules/@friggframework/eslint-config/**', 'node_modules/@friggframework/prettier-config/**', diff --git a/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js index b0c17039f..2a6984fd4 100644 --- a/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js +++ b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js @@ -984,3 +984,34 @@ describe('AdminScriptBuilder', () => { }); }); }); + +describe('AdminScriptBuilder nested node_modules (lambda.keepNestedNodeModules)', () => { + const appDefinition = (lambda) => ({ + adminScripts: [{ Definition: { name: 'test-script' } }], + ...(lambda && { lambda }), + }); + const executorAndRouter = (result) => [ + result.functions.adminScriptExecutor, + result.functions.adminScriptRouter, + ]; + + it('excludes every nested node_modules by default', async () => { + const result = await new AdminScriptBuilder().build(appDefinition(), {}); + + for (const fn of executorAndRouter(result)) { + expect(fn.package.exclude).toContain('node_modules/**/node_modules/**'); + } + }); + + it('keeps nested node_modules when the app opts in, still excluding nested Frigg copies', async () => { + const result = await new AdminScriptBuilder().build( + appDefinition({ keepNestedNodeModules: true }), + {} + ); + + for (const fn of executorAndRouter(result)) { + expect(fn.package.exclude).not.toContain('node_modules/**/node_modules/**'); + expect(fn.package.exclude).toContain('node_modules/**/node_modules/@friggframework/**'); + } + }); +}); diff --git a/packages/devtools/infrastructure/domains/database/migration-builder.js b/packages/devtools/infrastructure/domains/database/migration-builder.js index 36a0defb1..17884fcf5 100644 --- a/packages/devtools/infrastructure/domains/database/migration-builder.js +++ b/packages/devtools/infrastructure/domains/database/migration-builder.js @@ -15,6 +15,7 @@ const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder'); const { isScopedEnvironmentActive } = require('../shared/function-environments'); +const { nestedNodeModulesExcludes } = require('../shared/utilities/nested-node-modules'); const { MigrationResourceResolver } = require('./migration-resolver'); const { createEmptyDiscoveryResult, ResourceOwnership } = require('../shared/types'); @@ -235,7 +236,7 @@ class MigrationBuilder extends InfrastructureBuilder { * Create Lambda function definitions for database migrations * Based on refactor/add-better-support-for-commands branch implementation */ - async createFunctionDefinitions(result, usePrismaLayer = true) { + async createFunctionDefinitions(result, usePrismaLayer = true, appDefinition = {}) { console.log(' 🔍 DEBUG: createFunctionDefinitions called'); console.log(' 🔍 DEBUG: result.functions is:', typeof result.functions, result.functions); // Migration WORKER package config (needs Prisma CLI WASM files) @@ -250,8 +251,7 @@ class MigrationBuilder extends InfrastructureBuilder { ] : []), // But KEEP node_modules/prisma/** (the CLI with WASM) - // Exclude ALL nested node_modules - 'node_modules/**/node_modules/**', + ...nestedNodeModulesExcludes(appDefinition, usePrismaLayer), // Exclude AWS SDK (provided by Lambda runtime) 'node_modules/aws-sdk/**', @@ -343,8 +343,7 @@ class MigrationBuilder extends InfrastructureBuilder { // Router only skips Prisma CLI if Lambda Layer is enabled ...(usePrismaLayer ? ['node_modules/prisma/**'] : []), - // Exclude ALL nested node_modules - 'node_modules/**/node_modules/**', + ...nestedNodeModulesExcludes(appDefinition, usePrismaLayer), // Exclude AWS SDK (provided by Lambda runtime) 'node_modules/aws-sdk/**', @@ -516,7 +515,7 @@ class MigrationBuilder extends InfrastructureBuilder { console.log(' 🔍 DEBUG: result object before createFunctionDefinitions:', Object.keys(result)); // Create Lambda function definitions first (they reference the queue) - await this.createFunctionDefinitions(result, usePrismaLayer); + await this.createFunctionDefinitions(result, usePrismaLayer, appDefinition); console.log(' 🔍 DEBUG: result.functions after createFunctionDefinitions:', Object.keys(result.functions || {})); diff --git a/packages/devtools/infrastructure/domains/database/migration-builder.test.js b/packages/devtools/infrastructure/domains/database/migration-builder.test.js index ae8e32866..80759304c 100644 --- a/packages/devtools/infrastructure/domains/database/migration-builder.test.js +++ b/packages/devtools/infrastructure/domains/database/migration-builder.test.js @@ -424,3 +424,31 @@ describe('MigrationBuilder', () => { }); }); + +describe('MigrationBuilder nested node_modules (lambda.keepNestedNodeModules)', () => { + const baseAppDefinition = { database: { postgres: { enable: true } } }; + const workerAndRouter = (result) => [ + result.functions.dbMigrationWorker, + result.functions.dbMigrationRouter, + ]; + + it('excludes every nested node_modules by default', async () => { + const result = await new MigrationBuilder().build(baseAppDefinition, {}); + + for (const fn of workerAndRouter(result)) { + expect(fn.package.exclude).toContain('node_modules/**/node_modules/**'); + } + }); + + it('keeps nested node_modules when the app opts in, still excluding nested Frigg copies', async () => { + const result = await new MigrationBuilder().build( + { ...baseAppDefinition, lambda: { keepNestedNodeModules: true } }, + {} + ); + + for (const fn of workerAndRouter(result)) { + expect(fn.package.exclude).not.toContain('node_modules/**/node_modules/**'); + expect(fn.package.exclude).toContain('node_modules/**/node_modules/@friggframework/**'); + } + }); +}); diff --git a/packages/devtools/infrastructure/domains/integration/integration-builder.js b/packages/devtools/infrastructure/domains/integration/integration-builder.js index a27cd6dc7..75cd1f991 100644 --- a/packages/devtools/infrastructure/domains/integration/integration-builder.js +++ b/packages/devtools/infrastructure/domains/integration/integration-builder.js @@ -29,6 +29,9 @@ const { getIntegrationFunctionNames, getAdminFunctionNames, } = require('../shared/function-environments'); +const { + nestedNodeModulesExcludes, +} = require('../shared/utilities/nested-node-modules'); class IntegrationBuilder extends InfrastructureBuilder { constructor() { @@ -182,8 +185,10 @@ class IntegrationBuilder extends InfrastructureBuilder { usePrismaLayer = true ) { // Create package config first — needed by all Lambda functions including DLQ processor - const functionPackageConfig = - this.createFunctionPackageConfig(usePrismaLayer); + const functionPackageConfig = this.createFunctionPackageConfig( + usePrismaLayer, + appDefinition + ); // Create InternalErrorQueue if ownership = STACK const shouldCreateInternalErrorQueue = @@ -243,7 +248,7 @@ class IntegrationBuilder extends InfrastructureBuilder { /** * Create function package exclusion configuration */ - createFunctionPackageConfig(usePrismaLayer = true) { + createFunctionPackageConfig(usePrismaLayer = true, appDefinition = {}) { return { exclude: [ // Exclude AWS SDK (provided by Lambda runtime) @@ -260,8 +265,7 @@ class IntegrationBuilder extends InfrastructureBuilder { ] : []), - // Exclude ALL nested node_modules - 'node_modules/**/node_modules/**', + ...nestedNodeModulesExcludes(appDefinition, usePrismaLayer), // Exclude build tools (not needed at runtime) 'node_modules/esbuild/**', diff --git a/packages/devtools/infrastructure/domains/integration/integration-builder.test.js b/packages/devtools/infrastructure/domains/integration/integration-builder.test.js index 7ddabef10..6e6f90128 100644 --- a/packages/devtools/infrastructure/domains/integration/integration-builder.test.js +++ b/packages/devtools/infrastructure/domains/integration/integration-builder.test.js @@ -1032,3 +1032,33 @@ describe('IntegrationBuilder', () => { }); }); + +describe('IntegrationBuilder nested node_modules (lambda.keepNestedNodeModules)', () => { + const appDefinition = (lambda) => ({ + integrations: [{ Definition: { name: 'test', webhooks: true } }], + ...(lambda && { lambda }), + }); + const packagedFunctions = (result) => + Object.values(result.functions).filter((fn) => fn.package?.exclude); + + it('excludes every nested node_modules by default', async () => { + const result = await new IntegrationBuilder().build(appDefinition(), {}); + + expect(packagedFunctions(result).length).toBeGreaterThan(0); + for (const fn of packagedFunctions(result)) { + expect(fn.package.exclude).toContain('node_modules/**/node_modules/**'); + } + }); + + it('keeps nested node_modules when the app opts in, still excluding nested Frigg copies', async () => { + const result = await new IntegrationBuilder().build( + appDefinition({ keepNestedNodeModules: true }), + {} + ); + + for (const fn of packagedFunctions(result)) { + expect(fn.package.exclude).not.toContain('node_modules/**/node_modules/**'); + expect(fn.package.exclude).toContain('node_modules/**/node_modules/@friggframework/**'); + } + }); +}); diff --git a/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.js b/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.js index 3e318a1c6..fd70bd4a1 100644 --- a/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.js +++ b/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.js @@ -8,6 +8,7 @@ */ const { buildEnvironment } = require('../environment-builder'); +const { nestedNodeModulesExcludes } = require('./nested-node-modules'); /** * Create base serverless definition with core functions and resources @@ -64,8 +65,7 @@ function createBaseDefinition( 'node_modules/prettier/**', 'node_modules/eslint/**', - // Exclude ALL nested node_modules (catch any package with nested dependencies) - 'node_modules/**/node_modules/**', + ...nestedNodeModulesExcludes(AppDefinition, usePrismaLayer), // Exclude build tools (not needed at runtime) 'node_modules/esbuild/**', diff --git a/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.test.js b/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.test.js index fd69cc033..606b5a7df 100644 --- a/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.test.js +++ b/packages/devtools/infrastructure/domains/shared/utilities/base-definition-factory.test.js @@ -289,3 +289,31 @@ describe('Base Definition Factory', () => { }); }); + +describe('nested node_modules (lambda.keepNestedNodeModules)', () => { + const build = (appDefinition) => createBaseDefinition(appDefinition, {}, {}, true); + + it('excludes every nested node_modules from the core function packages by default', () => { + const result = build({ name: 'test-app' }); + + for (const fn of ['auth', 'user', 'health']) { + expect(result.functions[fn].package.exclude).toContain('node_modules/**/node_modules/**'); + } + }); + + it('keeps nested node_modules when the app opts in, still excluding nested Frigg, AWS SDK and Prisma copies', () => { + const result = build({ name: 'test-app', lambda: { keepNestedNodeModules: true } }); + + for (const fn of ['auth', 'user', 'health']) { + const { exclude } = result.functions[fn].package; + expect(exclude).not.toContain('node_modules/**/node_modules/**'); + expect(exclude).toEqual( + expect.arrayContaining([ + 'node_modules/**/node_modules/@friggframework/**', + 'node_modules/**/node_modules/@aws-sdk/**', + 'node_modules/**/node_modules/@prisma/**', + ]) + ); + } + }); +}); diff --git a/packages/devtools/infrastructure/domains/shared/utilities/nested-node-modules.js b/packages/devtools/infrastructure/domains/shared/utilities/nested-node-modules.js new file mode 100644 index 000000000..598815129 --- /dev/null +++ b/packages/devtools/infrastructure/domains/shared/utilities/nested-node-modules.js @@ -0,0 +1,53 @@ +/** + * Nested node_modules packaging patterns + * + * Utility Layer - Hexagonal Architecture + * + * npm nests a package only when the root copy cannot satisfy the dependant's + * range. Excluding every nested node_modules from a Lambda package therefore + * rewires module resolution to whatever is hoisted, and drops a package + * outright when the root copy is dev-only. Apps opt in to shipping the tree + * the way npm resolved it with `lambda.keepNestedNodeModules: true`. Nested + * copies of Frigg packages, the AWS SDK and Prisma stay excluded because the + * app's single core, the Lambda runtime and the Prisma layer provide them. + */ + +const ALL_NESTED_NODE_MODULES = 'node_modules/**/node_modules/**'; + +/** + * @param {Object} appDefinition + * @returns {boolean} + */ +function keepsNestedNodeModules(appDefinition = {}) { + return appDefinition.lambda?.keepNestedNodeModules === true; +} + +/** + * Package exclude patterns for nested node_modules directories. + * + * @param {Object} appDefinition + * @param {boolean} usePrismaLayer - Whether Prisma ships via the Lambda Layer + * @returns {string[]} + */ +function nestedNodeModulesExcludes(appDefinition, usePrismaLayer = true) { + if (!keepsNestedNodeModules(appDefinition)) { + return [ALL_NESTED_NODE_MODULES]; + } + return [ + 'node_modules/**/node_modules/@friggframework/**', + 'node_modules/**/node_modules/aws-sdk/**', + 'node_modules/**/node_modules/@aws-sdk/**', + ...(usePrismaLayer + ? [ + 'node_modules/**/node_modules/@prisma/**', + 'node_modules/**/node_modules/.prisma/**', + ] + : []), + ]; +} + +module.exports = { + ALL_NESTED_NODE_MODULES, + keepsNestedNodeModules, + nestedNodeModulesExcludes, +}; diff --git a/packages/devtools/infrastructure/domains/shared/utilities/nested-node-modules.test.js b/packages/devtools/infrastructure/domains/shared/utilities/nested-node-modules.test.js new file mode 100644 index 000000000..9424bb62c --- /dev/null +++ b/packages/devtools/infrastructure/domains/shared/utilities/nested-node-modules.test.js @@ -0,0 +1,61 @@ +/** + * Tests for nested node_modules packaging patterns + */ + +const { + ALL_NESTED_NODE_MODULES, + keepsNestedNodeModules, + nestedNodeModulesExcludes, +} = require('./nested-node-modules'); + +describe('nestedNodeModulesExcludes', () => { + it('excludes every nested node_modules by default', () => { + expect(nestedNodeModulesExcludes({}, true)).toEqual([ + 'node_modules/**/node_modules/**', + ]); + expect(ALL_NESTED_NODE_MODULES).toBe('node_modules/**/node_modules/**'); + }); + + it('keeps nested node_modules when the app opts in, except Frigg, AWS SDK and Prisma copies', () => { + const appDefinition = { lambda: { keepNestedNodeModules: true } }; + + const excludes = nestedNodeModulesExcludes(appDefinition, true); + + expect(excludes).not.toContain('node_modules/**/node_modules/**'); + expect(excludes).toEqual([ + 'node_modules/**/node_modules/@friggframework/**', + 'node_modules/**/node_modules/aws-sdk/**', + 'node_modules/**/node_modules/@aws-sdk/**', + 'node_modules/**/node_modules/@prisma/**', + 'node_modules/**/node_modules/.prisma/**', + ]); + }); + + it('keeps nested Prisma copies when the app bundles Prisma instead of using the layer', () => { + const appDefinition = { lambda: { keepNestedNodeModules: true } }; + + const excludes = nestedNodeModulesExcludes(appDefinition, false); + + expect(excludes).toEqual([ + 'node_modules/**/node_modules/@friggframework/**', + 'node_modules/**/node_modules/aws-sdk/**', + 'node_modules/**/node_modules/@aws-sdk/**', + ]); + }); +}); + +describe('keepsNestedNodeModules', () => { + it('is only enabled by the boolean true', () => { + expect( + keepsNestedNodeModules({ lambda: { keepNestedNodeModules: true } }) + ).toBe(true); + expect( + keepsNestedNodeModules({ + lambda: { keepNestedNodeModules: 'true' }, + }) + ).toBe(false); + expect(keepsNestedNodeModules({ lambda: {} })).toBe(false); + expect(keepsNestedNodeModules({})).toBe(false); + expect(keepsNestedNodeModules()).toBe(false); + }); +});