Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 3bf225c

Browse files
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

Lines changed: 5 additions & 1 deletion
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

Lines changed: 16 additions & 3 deletions
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

Lines changed: 218 additions & 0 deletions
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

Lines changed: 45 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /