Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/devtools/infrastructure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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/**',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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)
Expand All @@ -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/**',
Expand Down Expand Up @@ -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/**',
Expand Down Expand Up @@ -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 || {}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const {
getIntegrationFunctionNames,
getAdminFunctionNames,
} = require('../shared/function-environments');
const {
nestedNodeModulesExcludes,
} = require('../shared/utilities/nested-node-modules');

class IntegrationBuilder extends InfrastructureBuilder {
constructor() {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand All @@ -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/**',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

const { buildEnvironment } = require('../environment-builder');
const { nestedNodeModulesExcludes } = require('./nested-node-modules');

/**
* Create base serverless definition with core functions and resources
Expand Down Expand Up @@ -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/**',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**',
])
);
}
});
});
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the new Lambda option to the app-definition schema

When an app definition containing this documented opt-in is passed through @friggframework/schemas's validateAppDefinition, validation rejects it as an unknown property because packages/schemas/schemas/app-definition.schema.json allows only lambda.scopedEnvironment and sets additionalProperties: false on lambda. Add keepNestedNodeModules as a boolean schema property so schema-valid workflows can enable the new packaging behavior.

Useful? React with 👍 / 👎.

}

/**
* 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,
};
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading