Skip to content

Commit 3bf225c

Browse files
committed
feat(externals): implement an ability to package external modules
1 parent 7c8b29b commit 3bf225c

File tree

9 files changed

+566
-5
lines changed

9 files changed

+566
-5
lines changed

package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@
4444
"@aws-cdk/core": "^1.70.0",
4545
"@commitlint/cli": "^11.0.0",
4646
"@commitlint/config-conventional": "^11.0.0",
47+
"@types/fs-extra": "^9.0.2",
4748
"@types/jest": "^26.0.14",
4849
"@types/mock-fs": "^4.13.0",
4950
"@types/node": "^12.12.38",
51+
"@types/ramda": "^0.27.30",
5052
"@typescript-eslint/eslint-plugin": "^4.5.0",
5153
"@typescript-eslint/parser": "^4.5.0",
5254
"eslint": "^7.12.0",
@@ -58,7 +60,9 @@
5860
"typescript": "^4.0.3"
5961
},
6062
"dependencies": {
61-
"esbuild": ">=0.6"
63+
"esbuild": ">=0.6",
64+
"fs-extra": "^9.0.1",
65+
"ramda": "^0.27.1"
6266
},
6367
"peerDependencies": {
6468
"@aws-cdk/aws-lambda": "^1.0.0",

src/index.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import * as lambda from '@aws-cdk/aws-lambda';
22
import * as cdk from '@aws-cdk/core';
33
import * as es from 'esbuild';
44
import * as path from 'path';
5+
import { mergeRight, union, without } from 'ramda';
56

7+
import { packExternalModules } from './packExternalModules';
68
import { extractFileName, findProjectRoot, nodeMajorVersion } from './utils';
79

810
/**
@@ -37,6 +39,13 @@ export interface NodejsFunctionProps extends lambda.FunctionOptions {
3739
*/
3840
readonly runtime?: lambda.Runtime;
3941

42+
/**
43+
* The list of modules that must be excluded from bundle and from externals.
44+
*
45+
* @default = ['aws-sdk']
46+
*/
47+
readonly exclude?: string[];
48+
4049
/**
4150
* The esbuild bundler specific options.
4251
*
@@ -49,7 +58,6 @@ const BUILD_FOLDER = '.build';
4958
const DEFAULT_BUILD_OPTIONS: es.BuildOptions = {
5059
bundle: true,
5160
target: 'es2017',
52-
external: ['aws-sdk'],
5361
};
5462

5563
/**
@@ -66,6 +74,9 @@ export class NodejsFunction extends lambda.Function {
6674
throw new Error('Cannot find root directory. Please specify it with `rootDir` option.');
6775
}
6876

77+
const withDefaultOptions = mergeRight(DEFAULT_BUILD_OPTIONS);
78+
const buildOptions = withDefaultOptions<es.BuildOptions>(props.esbuildOptions ?? {});
79+
const exclude = union(props.exclude || [], ['aws-sdk']);
6980
const handler = props.handler ?? 'index.handler';
7081
const defaultRunTime = nodeMajorVersion() >= 12
7182
? lambda.Runtime.NODEJS_12_X
@@ -74,13 +85,15 @@ export class NodejsFunction extends lambda.Function {
7485
const entry = extractFileName(projectRoot, handler);
7586

7687
es.buildSync({
77-
...DEFAULT_BUILD_OPTIONS,
78-
...props.esbuildOptions,
88+
...buildOptions,
89+
external: union(exclude, buildOptions.external || []),
7990
entryPoints: [entry],
8091
outdir: path.join(projectRoot, BUILD_FOLDER, path.dirname(entry)),
8192
platform: 'node',
8293
});
8394

95+
packExternalModules(without(exclude, buildOptions.external || []), path.join(projectRoot, BUILD_FOLDER));
96+
8497
super(scope, id, {
8598
...props,
8699
runtime,

src/packExternalModules.ts

+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import * as fs from 'fs-extra';
2+
import * as path from 'path';
3+
import {
4+
compose,
5+
forEach,
6+
head,
7+
includes,
8+
is,
9+
isEmpty,
10+
join,
11+
map,
12+
mergeRight,
13+
pick,
14+
replace,
15+
split,
16+
startsWith,
17+
tail,
18+
toPairs,
19+
uniq,
20+
} from 'ramda';
21+
22+
import * as Packagers from './packagers';
23+
import { JSONObject } from './types';
24+
25+
function rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) {
26+
if (/^(?:file:[^/]{2}|\.\/|\.\.\/)/.test(moduleVersion)) {
27+
const filePath = replace(/^file:/, '', moduleVersion);
28+
return replace(
29+
/\\/g,
30+
'/',
31+
`${startsWith('file:', moduleVersion) ? 'file:' : ''}${pathToPackageRoot}/${filePath}`
32+
);
33+
}
34+
35+
return moduleVersion;
36+
}
37+
38+
/**
39+
* Add the given modules to a package json's dependencies.
40+
*/
41+
function addModulesToPackageJson(externalModules: string[], packageJson: JSONObject, pathToPackageRoot: string) {
42+
forEach(externalModule => {
43+
const splitModule = split('@', externalModule);
44+
// If we have a scoped module we have to re-add the @
45+
if (startsWith('@', externalModule)) {
46+
splitModule.splice(0, 1);
47+
splitModule[0] = '@' + splitModule[0];
48+
}
49+
let moduleVersion = join('@', tail(splitModule));
50+
// We have to rebase file references to the target package.json
51+
moduleVersion = rebaseFileReferences(pathToPackageRoot, moduleVersion);
52+
packageJson.dependencies = packageJson.dependencies || {};
53+
packageJson.dependencies[head(splitModule) ?? ''] = moduleVersion;
54+
}, externalModules);
55+
}
56+
57+
/**
58+
* Resolve the needed versions of production dependencies for external modules.
59+
*/
60+
function getProdModules(externalModules: { external: string }[], packagePath: string, dependencyGraph: JSONObject) {
61+
const packageJsonPath = path.join(process.cwd(), packagePath);
62+
// eslint-disable-next-line @typescript-eslint/no-var-requires
63+
const packageJson = require(packageJsonPath);
64+
const prodModules: string[] = [];
65+
66+
// only process the module stated in dependencies section
67+
if (!packageJson.dependencies) {
68+
return [];
69+
}
70+
71+
// Get versions of all transient modules
72+
forEach(externalModule => {
73+
const moduleVersion = packageJson.dependencies[externalModule.external];
74+
75+
if (moduleVersion) {
76+
prodModules.push(`${externalModule.external}@${moduleVersion}`);
77+
78+
// Check if the module has any peer dependencies and include them too
79+
try {
80+
const modulePackagePath = path.join(
81+
path.dirname(path.join(process.cwd(), packagePath)),
82+
'node_modules',
83+
externalModule.external,
84+
'package.json'
85+
);
86+
const peerDependencies = require(modulePackagePath).peerDependencies as Record<string, string>;
87+
if (!isEmpty(peerDependencies)) {
88+
console.log(`Adding explicit peers for dependency ${externalModule.external}`);
89+
const peerModules = getProdModules(
90+
compose(map(([external]) => ({ external })), toPairs)(peerDependencies),
91+
packagePath,
92+
dependencyGraph
93+
);
94+
Array.prototype.push.apply(prodModules, peerModules);
95+
}
96+
} catch (e) {
97+
console.log(`WARNING: Could not check for peer dependencies of ${externalModule.external}`);
98+
}
99+
} else {
100+
if (!packageJson.devDependencies || !packageJson.devDependencies[externalModule.external]) {
101+
prodModules.push(externalModule.external);
102+
} else {
103+
// To minimize the chance of breaking setups we whitelist packages available on AWS here. These are due to the previously missing check
104+
// most likely set in devDependencies and should not lead to an error now.
105+
const ignoredDevDependencies = ['aws-sdk'];
106+
107+
if (!includes(externalModule.external, ignoredDevDependencies)) {
108+
// Runtime dependency found in devDependencies but not forcefully excluded
109+
console.log(
110+
`ERROR: Runtime dependency '${externalModule.external}' found in devDependencies.`
111+
);
112+
throw new Error(`dependency error: ${externalModule.external}.`);
113+
}
114+
115+
console.log(
116+
`INFO: Runtime dependency '${externalModule.external}' found in devDependencies. It has been excluded automatically.`
117+
);
118+
}
119+
}
120+
}, externalModules);
121+
122+
return prodModules;
123+
}
124+
125+
/**
126+
* We need a performant algorithm to install the packages for each single
127+
* function (in case we package individually).
128+
* (1) We fetch ALL packages needed by ALL functions in a first step
129+
* and use this as a base npm checkout. The checkout will be done to a
130+
* separate temporary directory with a package.json that contains everything.
131+
* (2) For each single compile we copy the whole node_modules to the compile
132+
* directory and create a (function) compile specific package.json and store
133+
* it in the compile directory. Now we start npm again there, and npm will just
134+
* remove the superfluous packages and optimize the remaining dependencies.
135+
* This will utilize the npm cache at its best and give us the needed results
136+
* and performance.
137+
*/
138+
export function packExternalModules(externals: string[], compositeModulePath: string) {
139+
if (!externals || !externals.length) {
140+
return;
141+
}
142+
143+
// Read plugin configuration
144+
const packagePath = './package.json';
145+
const packageJsonPath = path.join(process.cwd(), packagePath);
146+
147+
// Determine and create packager
148+
const packager = Packagers.get(Packagers.Installer.NPM);
149+
150+
// Fetch needed original package.json sections
151+
const sectionNames = packager.copyPackageSectionNames;
152+
const packageJson = fs.readJsonSync(packageJsonPath);
153+
const packageSections = pick(sectionNames, packageJson);
154+
155+
// Get first level dependency graph
156+
console.log(`Fetch dependency graph from ${packageJsonPath}`);
157+
158+
const dependencyGraph = packager.getProdDependencies(path.dirname(packageJsonPath), 1);
159+
160+
// (1) Generate dependency composition
161+
const externalModules = map(external => ({ external }), externals);
162+
const compositeModules: JSONObject = uniq(getProdModules(externalModules, packagePath, dependencyGraph));
163+
164+
if (isEmpty(compositeModules)) {
165+
// The compiled code does not reference any external modules at all
166+
console.log('No external modules needed');
167+
return;
168+
}
169+
170+
// (1.a) Install all needed modules
171+
const compositePackageJson = path.join(compositeModulePath, 'package.json');
172+
173+
// (1.a.1) Create a package.json
174+
const compositePackage = mergeRight(
175+
{
176+
name: 'externals',
177+
version: '1.0.0',
178+
description: `Packaged externals for ${'externals'}`,
179+
private: true,
180+
},
181+
packageSections
182+
);
183+
const relativePath = path.relative(compositeModulePath, path.dirname(packageJsonPath));
184+
addModulesToPackageJson(compositeModules, compositePackage, relativePath);
185+
fs.writeJsonSync(compositePackageJson, compositePackage);
186+
187+
// (1.a.2) Copy package-lock.json if it exists, to prevent unwanted upgrades
188+
const packageLockPath = path.join(path.dirname(packageJsonPath), packager.lockfileName);
189+
190+
if (fs.existsSync(packageLockPath)) {
191+
console.log('Package lock found - Using locked versions');
192+
try {
193+
let packageLockFile = fs.readJsonSync(packageLockPath);
194+
packageLockFile = packager.rebaseLockfile(relativePath, packageLockFile);
195+
if (is(Object)(packageLockFile)) {
196+
packageLockFile = JSON.stringify(packageLockFile, null, 2);
197+
}
198+
199+
fs.writeJsonSync(path.join(compositeModulePath, packager.lockfileName), packageLockFile);
200+
} catch (err) {
201+
console.log(`Warning: Could not read lock file: ${err.message}`);
202+
}
203+
}
204+
205+
const start = Date.now();
206+
console.log('Packing external modules: ' + compositeModules.join(', '));
207+
208+
packager.install(compositeModulePath);
209+
210+
console.log(`Package took [${Date.now() - start} ms]`);
211+
212+
// Prune extraneous packages - removes not needed ones
213+
const startPrune = Date.now();
214+
215+
packager.prune(compositeModulePath);
216+
217+
console.log(`Prune: ${compositeModulePath} [${Date.now() - startPrune} ms]`);
218+
}

src/packagers/index.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Factory for supported packagers.
3+
*
4+
* All packagers must implement the following interface:
5+
*
6+
* interface Packager {
7+
*
8+
* static get lockfileName(): string;
9+
* static get copyPackageSectionNames(): Array<string>;
10+
* static get mustCopyModules(): boolean;
11+
* static getProdDependencies(cwd: string, depth: number = 1): Object;
12+
* static rebaseLockfile(pathToPackageRoot: string, lockfile: Object): void;
13+
* static install(cwd: string): void;
14+
* static prune(cwd: string): void;
15+
* static runScripts(cwd: string, scriptNames): void;
16+
*
17+
* }
18+
*/
19+
20+
import { Packager } from './packager';
21+
import { NPM } from './npm';
22+
import { Yarn } from './yarn';
23+
24+
const registeredPackagers = {
25+
npm: new NPM(),
26+
yarn: new Yarn()
27+
};
28+
29+
export enum Installer {
30+
NPM = 'npm',
31+
YARN = 'yarn',
32+
}
33+
34+
/**
35+
* Factory method.
36+
* @param {string} packagerId - Well known packager id.
37+
*/
38+
export function get(packagerId: Installer): Packager {
39+
if (!(packagerId in registeredPackagers)) {
40+
const message = `Could not find packager '${packagerId}'`;
41+
console.log(`ERROR: ${message}`);
42+
throw new Error(message);
43+
}
44+
return registeredPackagers[packagerId];
45+
}

0 commit comments

Comments
 (0)