Skip to content

Commit 8bd869d

Browse files
mildanielgojko
andauthored
Enable esbuild in the Node.js builder (#307)
* upgrade integration tests to use nodejs14.x instead of obsolete 8.10; remove garbage lockfile left by npm 7 * esbuild bundling for javascript * initial esbuild support * aws-sam -> aws_sam, revert to node 10 for integration tests, document package.json options * rename main -> entry_point, support multiple entrypoints * Update Appveyor nodejs version * Testing different node version * Update npm to version that supports node14 * Cleanup garbage package lock in dependencies dir generated by npm 7 * PEP8 compliance and missing doc strings * Add support for auto dep layer and incremental build with esbuild * Fix documentation * Revert accelerate feature changes * Add experimental feature flag, route all requests to old workflow * Add experimental feature flag, route all requests to old workflow * Remove unused variable * Black reformat Co-authored-by: Gojko Adzic <[email protected]>
1 parent f95a043 commit 8bd869d

File tree

35 files changed

+1629
-77
lines changed

35 files changed

+1629
-77
lines changed

.appveyor.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ image:
66
environment:
77
GOVERSION: 1.11
88
GRADLE_OPTS: -Dorg.gradle.daemon=false
9-
nodejs_version: "10.10.0"
9+
nodejs_version: "14.17.6"
1010

1111
matrix:
1212
- PYTHON: "C:\\Python36-x64"
@@ -93,7 +93,7 @@ for:
9393
- sh: "source ${HOME}/venv${PYTHON_VERSION}/bin/activate"
9494
- sh: "rvm use 2.5"
9595
- sh: "nvm install ${nodejs_version}"
96-
- sh: "npm install npm@5.6.0 -g"
96+
- sh: "npm install npm@7.24.2 -g"
9797
- sh: "npm -v"
9898
- sh: "echo $PATH"
9999
- sh: "java --version"

aws_lambda_builders/builder.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ def build(
6969
dependencies_dir=None,
7070
combine_dependencies=True,
7171
architecture=X86_64,
72+
experimental_flags=None,
7273
):
74+
# pylint: disable-msg=too-many-locals
7375
"""
7476
Actually build the code by running workflows
7577
@@ -127,6 +129,10 @@ def build(
127129
:type architecture: str
128130
:param architecture:
129131
Type of architecture x86_64 and arm64 for Lambda Function
132+
133+
:type experimental_flags: list
134+
:param experimental_flags:
135+
List of strings, which will indicate enabled experimental flags for the current build session
130136
"""
131137

132138
if not os.path.exists(scratch_dir):
@@ -146,6 +152,7 @@ def build(
146152
dependencies_dir=dependencies_dir,
147153
combine_dependencies=combine_dependencies,
148154
architecture=architecture,
155+
experimental_flags=experimental_flags,
149156
)
150157

151158
return workflow.run()

aws_lambda_builders/workflow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def __init__(
164164
dependencies_dir=None,
165165
combine_dependencies=True,
166166
architecture=X86_64,
167+
experimental_flags=None,
167168
):
168169
"""
169170
Initialize the builder with given arguments. These arguments together form the "public API" that each
@@ -200,6 +201,8 @@ def __init__(
200201
from dependency_folder into build folder
201202
architecture : str, optional
202203
Architecture type either arm64 or x86_64 for which the build will be based on in AWS lambda, by default X86_64
204+
experimental_flags: list, optional
205+
List of strings, which will indicate enabled experimental flags for the current build session
203206
"""
204207

205208
self.source_dir = source_dir
@@ -215,6 +218,7 @@ def __init__(
215218
self.dependencies_dir = dependencies_dir
216219
self.combine_dependencies = combine_dependencies
217220
self.architecture = architecture
221+
self.experimental_flags = experimental_flags if experimental_flags else []
218222

219223
# Actions are registered by the subclasses as they seem fit
220224
self.actions = []

aws_lambda_builders/workflows/nodejs_npm/DESIGN.md

Lines changed: 256 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22

33
### Scope
44

5-
This package is an effort to port the Claudia.JS packager to a library that can
6-
be used to handle the dependency resolution portion of packaging NodeJS code
7-
for use in AWS Lambda. The scope for this builder is to take an existing
5+
The scope for this builder is to take an existing
86
directory containing customer code, including a valid `package.json` manifest
97
specifying third-party dependencies. The builder will use NPM to include
108
production dependencies and exclude test resources in a way that makes them
@@ -24,9 +22,16 @@ To speed up Lambda startup time and optimise usage costs, the correct thing to
2422
do in most cases is just to package up production dependencies. During development
2523
work we can expect that the local `node_modules` directory contains all the
2624
various dependency types, and NPM does not provide a way to directly identify
27-
just the ones relevant for production. To identify production dependencies,
28-
this packager needs to copy the source to a clean temporary directory and re-run
29-
dependency installation there.
25+
just the ones relevant for production.
26+
27+
There are two ways to include only production dependencies in a package:
28+
29+
1. **without a bundler**: Copy the source to a clean temporary directory and
30+
re-run dependency installation there.
31+
32+
2. **with a bundler**: Apply a javascript code bundler (such as `esbuild` or
33+
`webpack`) to produce a single-file javascript bundle by recursively
34+
resolving included dependencies, starting from the main lambda handler.
3035

3136
A frequently used trick to speed up NodeJS Lambda deployment is to avoid
3237
bundling the `aws-sdk`, since it is already available on the Lambda VM.
@@ -53,7 +58,9 @@ far from optimal to create a stand-alone module. Copying would lead to significa
5358
larger packages than necessary, as sub-modules might still have test resources, and
5459
common references from multiple projects would be duplicated.
5560

56-
NPM also uses a locking mechanism (`package-lock.json`) that's in many ways more
61+
NPM also uses two locking mechanisms (`package-lock.json` and `npm-shrinkwrap.json`)
62+
that can be used to freeze versions of dependencies recursively, and provide reproducible
63+
builds. Before version 7, the locking mechanism was in many ways more
5764
broken than functional, as it in some cases hard-codes locks to local disk
5865
paths, and gets confused by including the same package as a dependency
5966
throughout the project tree in different dependency categories
@@ -73,10 +80,96 @@ To fully deal with those cases, this packager may need to execute the
7380
dependency installation step on a Docker image compatible with the target
7481
Lambda environment.
7582

76-
### Implementation
83+
### Choosing the packaging type
84+
85+
For a large majority of projects, packaging using a bundler has significant
86+
advantages (speed and runtime package size, supporting local dependencies).
87+
88+
However, there are also some drawbacks to using a bundler for a small set of
89+
use cases (namely including packages with binary dependencies, such as `sharp`, a
90+
popular image processing library).
91+
92+
Because of this, it's important to support both ways of packaging. The version
93+
without a bundler is slower, but will be correct in case of binary dependencies.
94+
For backwards compatibility, this should be the default.
95+
96+
Users should be able to activate packaging with a bundler for projects where that
97+
is safe to do, such as those without any binary dependencies.
98+
99+
The proposed approach is to use a "aws-sam" property in the package manifest
100+
(`package.json`). If the `nodejs_npm` Lambda builder finds a matching property, it
101+
knows that it is safe to use the bundler to package.
102+
103+
The rest of this section outlines the major differences between packaging with
104+
and without a bundler.
105+
106+
#### packaging speed
107+
108+
Packaging without a bundler is slower than using a bundler, as it
109+
requires copying the project to a clean working directory, installing
110+
dependencies and archiving into a single ZIP.
111+
112+
Packaging with a bundler runs directly on files already on the disk, without
113+
the need to copy or move files around. This approach can use the fast `npm ci`
114+
command to just ensure that the dependencies are present on the disk instead of
115+
always downloading all the dependencies.
116+
117+
#### additional tools
118+
119+
Packaging without a bundler does not require additional tools installed on the
120+
development environment or CI systems, as it can just work with NPM.
121+
122+
Packaging with a bundler requires installing additional tools (eg `esbuild`).
123+
124+
#### handling local dependencies
125+
126+
Packaging without a bundler requires complex
127+
rewriting to handle local dependencies, and recursively packaging archives. In
128+
theory, this was going to be implemented as a subsequent release after the
129+
initial version of the `npm_nodejs` builder, but due to issues with container
130+
environments and how `aws-lambda-builders` mounts the working directory, it was
131+
not added for several years, and likely will not be implemented soon.
132+
133+
Packaging with a bundler can handle local dependencies out of the box, since
134+
it just traverses relative file liks.
135+
136+
#### including non-javascript files
137+
138+
Packaging without a bundler zips up entire contents of NPM packages.
139+
140+
Packaging with a bundler only locates JavaScript files in the dependency tree.
141+
142+
Some NPM packages include important binaries or resources in the NPM package,
143+
which would not be included in the package without a bundler. This means that
144+
packaging using a bundler is not universally applicable, and may never fully
145+
replace packaging without a bundler.
146+
147+
Some NPM packages include a lot of additional files not required at runtime.
148+
`aws-sdk` for JavaScript (v2) is a good example, including TypeScript type
149+
definitions, documentation and REST service definitions for automated code
150+
generators. Packaging without a bundler includes these files as well,
151+
unnecessarily increasing Lambda archive size. Packaging with a bundler just
152+
ignores all these additional files out of the box.
153+
154+
#### error reporting
155+
156+
Packaging without a bundler leaves original file names and line numbers, ensuring
157+
that any stack traces or exception reports correspond directly to the original
158+
source files.
159+
160+
Packaging with a bundler creates a single file from all the dependencies, so
161+
stack traces on production no longer correspond to original source files. As a
162+
workaround, bundlers can include a 'source map' file, to allow translating
163+
production stack traces into source stack traces. Prior to Node 14, this
164+
required including a separate NPM package, or additional tools. Since Node 14,
165+
stack trace translation can be [activated using an environment
166+
variable](https://serverless.pub/aws-lambda-node-sourcemaps/)
167+
168+
169+
### Implementation without a bundler
77170

78171
The general algorithm for preparing a node package for use on AWS Lambda
79-
is as follows.
172+
without a JavaScript bundler (`esbuild` or `webpack`) is as follows.
80173

81174
#### Step 1: Prepare a clean copy of the project source files
82175

@@ -134,3 +227,157 @@ To fully support dependencies that download or compile binaries for a target pla
134227
needs to be executed inside a Docker image compatible with AWS Lambda.
135228
_(out of scope for the current version)_
136229

230+
### Implementation with a bundler
231+
232+
The general algorithm for preparing a node package for use on AWS Lambda
233+
with a bundler (`esbuild` or `webpack`) is as follows.
234+
235+
#### Step 1: ensure production dependencies are installed
236+
237+
If the directory contains `package-lock.json` or `npm-shrinkwrap.json`,
238+
execute [`npm ci`](https://docs.npmjs.com/cli/v7/commands/npm-ci). This
239+
operation is designed to be faster than installing dependencies using `npm install`
240+
in automated CI environments.
241+
242+
If the directory does not contain lockfiles, but contains `package.json`,
243+
execute [`npm install --production`] to download production dependencies.
244+
245+
#### Step 2: bundle the main Lambda file
246+
247+
Execute `esbuild` to produce a single JavaScript file by recursively resolving
248+
included dependencies, and optionally a source map.
249+
250+
Ensure that the target file name is the same as the entry point of the Lambda
251+
function, so that there is no impact on the CloudFormation template.
252+
253+
254+
### Activating the bundler workflow
255+
256+
Because there are advantages and disadvantages to both approaches (with and
257+
without a bundler), the user should be able to choose between them. The default
258+
is not to use a bundler (both because it's universally applicable and for
259+
backwards compatibility). Node.js pakage manifests (`package.json`) allow for
260+
custom properties, so a user can activate the bundler process by providing an
261+
`aws_sam` configuration property in the package manifest. If this property is
262+
present in the package manifest, and the sub-property `bundler` equals
263+
`esbuild`, the Node.js NPM Lambda builder activates the bundler process.
264+
265+
Because the Lambda builder workflow is not aware of the main lambda function
266+
definition, (the file containing the Lambda handler function) the user must
267+
also specify the main entry point for bundling . This is a bit of an
268+
unfortunate duplication with SAM Cloudformation template, but with the current
269+
workflow design there is no way around it.
270+
271+
In addition, as a single JavaScript source package can contain multiple functions,
272+
and can be included multiple times in a single CloudFormation template, it's possible
273+
that there may be multiple entry points for bundling. SAM build executes the build
274+
only once for the function in this case, so all entry points have to be bundled
275+
at once.
276+
277+
The following example is a minimal `package.json` to activate the `esbuild` bundler
278+
on a javascript file, starting from `lambda.js`. It will produce a bundled `lambda.js`
279+
in the artifacts folder.
280+
281+
```json
282+
{
283+
"name": "nodeps-esbuild",
284+
"version": "1.0.0",
285+
"license": "APACHE2.0",
286+
"aws_sam": {
287+
"bundler": "esbuild",
288+
"entry_points": ["lambda.js"]
289+
}
290+
}
291+
```
292+
293+
#### Locating the esbuild binary
294+
295+
`esbuild` supports platform-independent binary distribution using NPM, by
296+
including the `esbuild` package as a dependency. The Lambda builder should
297+
first try to locate the binary in the Lambda code repository (allowing the
298+
user to include a specific version). Failing that, the Lambda builder should
299+
try to locate the `esbuild` binary in the `executable_search_paths` configured
300+
for the workflow, then the operating system `PATH` environment variable.
301+
302+
The Lambda builder **should not** bring its own `esbuild` binary, but it should
303+
clearly point to the error when one is not found, to allow users to configure the
304+
build correctly.
305+
306+
In the previous example, the esbuild binary is not included in the package dependencies,
307+
so the Lambda builder will use the system executable paths to search for it. In the
308+
example below, `esbuild` is included in the package, so the Lambda builder should use it
309+
directly.
310+
311+
```json
312+
{
313+
"name": "with-deps-esbuild",
314+
"version": "1.0.0",
315+
"license": "APACHE2.0",
316+
"aws_sam": {
317+
"bundler": "esbuild",
318+
"entry_points": ["lambda.js"]
319+
},
320+
"devDependencies": {
321+
"esbuild": "^0.11.23"
322+
}
323+
}
324+
```
325+
326+
For a full example, see the [`with-deps-esbuild`](../../../tests/integration/workflows/nodejs_npm/testdata/with-deps-esbuild/) test project.
327+
328+
#### Building typescript
329+
330+
`esbuild` supports bundling typescript out of the box and transpiling it to plain
331+
javascript. The user just needs to point to a typescript file as the main entry point,
332+
as in the example below. There is no transpiling process needed upfront.
333+
334+
335+
```js
336+
{
337+
"name": "with-deps-esbuild-typescript",
338+
"version": "1.0.0",
339+
"license": "APACHE2.0",
340+
"aws_sam": {
341+
"bundler": "esbuild",
342+
"entry_points": ["included.ts"]
343+
},
344+
"dependencies": {
345+
"@types/aws-lambda": "^8.10.76"
346+
},
347+
"devDependencies": {
348+
"esbuild": "^0.11.23"
349+
}
350+
}
351+
```
352+
353+
For a full example, see the [`with-deps-esbuild-typescript`](../../../tests/integration/workflows/nodejs_npm/testdata/with-deps-esbuild-typescript/) test project.
354+
355+
**important note:** esbuild does not perform type checking, so users wanting to ensure type-checks need to run the `tsc` process as part of their
356+
testing flow before invoking `sam build`. For additional typescript caveats with esbuild, check out <https://esbuild.github.io/content-types/#typescript>.
357+
358+
#### Configuring the bundler
359+
360+
The Lambda builder invokes `esbuild` with sensible defaults that will work for the majority of cases. Importantly, the following three parameters are set by default
361+
362+
* `--minify`, as it [produces a smaller runtime package](https://esbuild.github.io/api/#minify)
363+
* `--sourcemap`, as it generates a [source map that allows for correct stack trace reporting](https://esbuild.github.io/api/#sourcemap) in case of errors (see the [Error reporting](#error-reporting) section above)
364+
* `--target es2020`, as it allows for javascript features present in Node 14
365+
366+
Users might want to tweak some of these runtime arguments for a specific project, for example not including the source map to further reduce the package size, or restricting javascript features to an older version. The Lambda builder allows this with optional sub-properties of the `aws_sam` configuration property.
367+
368+
* `target`: string, corresponding to a supported [esbuild target](https://esbuild.github.io/api/#target) property
369+
* `minify`: boolean, defaulting to `true`
370+
* `sourcemap`: boolean, defaulting to `true`
371+
372+
Here is an example that deactivates minification and source maps, and supports JavaScript features compatible with Node.js version 10.
373+
374+
```json
375+
{
376+
"aws_sam": {
377+
"bundler": "esbuild",
378+
"entry_points": ["included.ts"],
379+
"target": "node10",
380+
"minify": false,
381+
"sourcemap": false
382+
}
383+
}

0 commit comments

Comments
 (0)