├── .commitlintrc.yml ├── .editorconfig ├── .eslintrc.yml ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .huskyrc.yml ├── .releaserc.yml ├── LICENSE ├── README.md ├── _config.yml ├── examples ├── complete │ ├── .gitignore │ ├── README.md │ ├── cdk.json │ ├── package.json │ ├── src │ │ └── index.ts │ ├── stack │ │ ├── app.ts │ │ └── stack.ts │ └── tsconfig.json └── minimal │ ├── .gitignore │ ├── README.md │ ├── cdk.json │ ├── index.js │ ├── package.json │ └── stack │ ├── app.js │ └── stack.js ├── jest.config.js ├── package.json ├── src ├── function.ts ├── index.ts ├── pack-externals.ts ├── packagers │ ├── index.ts │ ├── npm.ts │ ├── packager.ts │ └── yarn.ts ├── props.ts ├── types.ts └── utils.ts ├── tests ├── function.test.ts └── packagers │ ├── index.test.ts │ ├── npm.test.ts │ └── yarn.test.ts └── tsconfig.json /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - '@commitlint/config-conventional' 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | ignorePatterns: 4 | - dist 5 | extends: 6 | - eslint:recommended 7 | - plugin:@typescript-eslint/recommended 8 | rules: 9 | semi: 'off' 10 | quotes: 'off' 11 | no-use-before-define: 'off' 12 | '@typescript-eslint/no-var-requires': 'off' 13 | '@typescript-eslint/explicit-function-return-type': 'off' 14 | '@typescript-eslint/explicit-module-boundary-types': 'off' 15 | '@typescript-eslint/semi': error 16 | '@typescript-eslint/quotes': 17 | - error 18 | - single 19 | '@typescript-eslint/no-use-before-define': 20 | - error 21 | - functions: false 22 | classes: false 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: floydspace 2 | patreon: floydspace 3 | issuehunt: floydspace 4 | ko_fi: floydspace 5 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - beta 7 | - alpha 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [10.x, 12.x, 14.x] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | - run: npm run build --if-present 23 | coverage: 24 | needs: test 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v1 29 | with: 30 | node-version: 12 31 | - run: npm install 32 | - run: npm run coverage 33 | - uses: coverallsapp/github-action@master 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | publish: 37 | needs: test 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v2 41 | - uses: actions/setup-node@v1 42 | with: 43 | node-version: 12 44 | - run: npm install 45 | - run: npx semantic-release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .idea 4 | dist 5 | *.log 6 | package-lock.json 7 | coverage 8 | -------------------------------------------------------------------------------- /.huskyrc.yml: -------------------------------------------------------------------------------- 1 | hooks: 2 | commit-msg: commitlint -E HUSKY_GIT_PARAMS 3 | pre-push: npm test 4 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | preset: conventionalcommits 2 | branches: 3 | - name: master 4 | - name: beta 5 | prerelease: true 6 | channel: false 7 | - name: alpha 8 | prerelease: true 9 | channel: false 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Victor Korzunin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | λ💨 aws-lambda-nodejs-esbuild 2 | ============== 3 | 4 | [AWS CDK](https://aws.amazon.com/cdk/) Construct to build Node.js AWS lambdas using [esbuild](https://esbuild.github.io). 5 | 6 | ![CDK Construct NodeJS](https://img.shields.io/badge/cdk--construct-node.js-blue?logo=amazon-aws&color=43853d) 7 | [![Build Status](https://img.shields.io/github/workflow/status/floydspace/aws-lambda-nodejs-esbuild/release)](https://github.com/floydspace/aws-lambda-nodejs-esbuild/actions) 8 | [![Coverage Status](https://coveralls.io/repos/github/floydspace/aws-lambda-nodejs-esbuild/badge.svg?branch=master)](https://coveralls.io/github/floydspace/aws-lambda-nodejs-esbuild?branch=master) 9 | [![npm version](https://badge.fury.io/js/aws-lambda-nodejs-esbuild.svg)](https://badge.fury.io/js/aws-lambda-nodejs-esbuild) 10 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 11 | [![Mentioned in Awesome CDK](https://awesome.re/mentioned-badge.svg)](https://github.com/kolomied/awesome-cdk) 12 | 13 | 14 | Table of Contents 15 | ----------------- 16 | - [Features](#features) 17 | - [Installation](#installation) 18 | - [Configure](#configure) 19 | - [Usage](#usage) 20 | - [Author](#author) 21 | 22 | 23 | Features 24 | -------- 25 | 26 | * Zero-config: Works out of the box without the need to install any other packages 27 | * Supports ESNext and TypeScript syntax with transforming limitations (See *Note*) 28 | 29 | *Note*: The default JavaScript syntax target is set to [`ES2017`](https://node.green/#ES2017), so the final bundle will be supported by all [AWS Lambda Node.js runtimes](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). If you still using an old lambda runtime and have to respect it you can play with esbuild `target` option, see [JavaScript syntax support](https://esbuild.github.io/content-types/#javascript) for more details about syntax transform limitations. 30 | 31 | 32 | Installation 33 | ------------ 34 | 35 | ```sh 36 | yarn add --dev @aws-cdk/aws-lambda aws-lambda-nodejs-esbuild 37 | # or 38 | npm install -D @aws-cdk/aws-lambda aws-lambda-nodejs-esbuild 39 | ``` 40 | 41 | 42 | Configure 43 | --------- 44 | 45 | By default, no configuration required, but you can change esbuild behavior: 46 | 47 | ```ts 48 | import * as cdk from '@aws-cdk/core'; 49 | import { NodejsFunction } from 'aws-lambda-nodejs-esbuild'; 50 | 51 | class NewStack extends cdk.Stack { 52 | constructor(scope, id, props) { 53 | super(scope, id, props); 54 | 55 | new NodejsFunction(this, 'NewFunction', { 56 | esbuildOptions: { 57 | minify: false, // default 58 | target: 'ES2017', 59 | } 60 | }); 61 | } 62 | } 63 | ``` 64 | 65 | Check [esbuild](https://esbuild.github.io/api/#simple-options) documentation for the full list of available options. Note that some options like `entryPoints` or `outdir` cannot be overwritten. 66 | The package specified in the `exclude` option is passed to esbuild as `external`, but it is not included in the function bundle either. The default value for this option is `['aws-sdk']`. 67 | 68 | 69 | Usage 70 | ----- 71 | 72 | The normal AWS CDK deploy procedure will automatically compile with `esbuild`: 73 | 74 | - Create the AWS CDK project with `cdk init app --language=typescript` 75 | - Install `aws-lambda-nodejs-esbuild` as above 76 | - Deploy with `cdk deploy` 77 | 78 | See examples: [minimal](examples/minimal/README.md) and [complete](examples/complete/README.md) 79 | 80 | 81 | Author 82 | ------ 83 | 84 | [Victor Korzunin](https://floydspace.github.io/) 85 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | -------------------------------------------------------------------------------- /examples/complete/.gitignore: -------------------------------------------------------------------------------- 1 | # CDK asset staging directory 2 | .cdk.staging 3 | cdk.out 4 | .build 5 | -------------------------------------------------------------------------------- /examples/complete/README.md: -------------------------------------------------------------------------------- 1 | # [aws-lambda-nodejs-esbuild](../../README.md) complete example 2 | 3 | This example shows how to use the `aws-lambda-nodejs-esbuild` construct in the most common way. 4 | 5 | Any package set as `external` in the `esbuildOptions` will not be bundled into the output file, but packed as a `node_modules` dependency. 6 | 7 | If packing a package is not required, for instance if it exists in a layer, you may set it in the option `exclude`, so it will neither be packed nor bundled. `aws-sdk` is excluded by default. 8 | -------------------------------------------------------------------------------- /examples/complete/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node stack/app.ts", 3 | "versionReporting": false 4 | } 5 | -------------------------------------------------------------------------------- /examples/complete/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "complete", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "@aws-cdk/aws-lambda": "^1.70.0", 6 | "@aws-cdk/core": "^1.70.0", 7 | "@types/node": "10.17.27", 8 | "aws-cdk": "1.70.0", 9 | "aws-lambda-nodejs-esbuild": "*", 10 | "ts-node": "^8.1.0", 11 | "typescript": "~3.9.7" 12 | }, 13 | "dependencies": { 14 | "isin-validator": "^1.1.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/complete/src/index.ts: -------------------------------------------------------------------------------- 1 | import validateIsin from 'isin-validator'; 2 | 3 | export function handler(event: string) { 4 | const isInvalid = validateIsin(event); 5 | 6 | return { 7 | statusCode: 200, 8 | body: JSON.stringify({ 9 | message: isInvalid ? 'ISIN is invalid!' : 'ISIN is fine!', 10 | input: event, 11 | }), 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /examples/complete/stack/app.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from '@aws-cdk/core'; 2 | import { CompleteStack } from './stack'; 3 | 4 | const app = new cdk.App(); 5 | new CompleteStack(app, 'CompleteStack'); 6 | -------------------------------------------------------------------------------- /examples/complete/stack/stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from '@aws-cdk/core'; 2 | import { NodejsFunction } from 'aws-lambda-nodejs-esbuild'; 3 | 4 | export class CompleteStack extends cdk.Stack { 5 | constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { 6 | super(scope, id, props); 7 | 8 | new NodejsFunction(this, 'CompleteExampleFunction', { 9 | handler: 'src/index.handler', 10 | esbuildOptions: { 11 | external: ['isin-validator'] 12 | } 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/complete/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"] 21 | }, 22 | "exclude": ["cdk.out"] 23 | } 24 | -------------------------------------------------------------------------------- /examples/minimal/.gitignore: -------------------------------------------------------------------------------- 1 | # CDK asset staging directory 2 | .cdk.staging 3 | cdk.out 4 | .build 5 | -------------------------------------------------------------------------------- /examples/minimal/README.md: -------------------------------------------------------------------------------- 1 | # [aws-lambda-nodejs-esbuild](../../README.md) minimal example 2 | 3 | This example shows how to use the `aws-lambda-nodejs-esbuild` construct with default options. 4 | 5 | If you do not provide a `handler` option it assumes that you define a lambda handler as `index.js` file in root folder. 6 | 7 | By default it bundles all dependencies in a single file and transpiles to the `ES2017` target. 8 | -------------------------------------------------------------------------------- /examples/minimal/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "node stack/app.js", 3 | "versionReporting": false 4 | } 5 | -------------------------------------------------------------------------------- /examples/minimal/index.js: -------------------------------------------------------------------------------- 1 | const validateIsin = require('isin-validator'); 2 | 3 | module.exports.handler = (event) => { 4 | const isInvalid = validateIsin(event); 5 | 6 | return { 7 | statusCode: 200, 8 | body: JSON.stringify({ 9 | message: isInvalid ? 'ISIN is invalid!' : 'ISIN is fine!', 10 | input: event, 11 | }), 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /examples/minimal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "@aws-cdk/aws-lambda": "^1.70.0", 6 | "@aws-cdk/core": "1.70.0", 7 | "aws-cdk": "1.70.0", 8 | "aws-lambda-nodejs-esbuild": "*" 9 | }, 10 | "dependencies": { 11 | "isin-validator": "^1.1.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/minimal/stack/app.js: -------------------------------------------------------------------------------- 1 | const cdk = require('@aws-cdk/core'); 2 | const { MinimalStack } = require('./stack'); 3 | 4 | const app = new cdk.App(); 5 | new MinimalStack(app, 'MinimalStack'); 6 | -------------------------------------------------------------------------------- /examples/minimal/stack/stack.js: -------------------------------------------------------------------------------- 1 | const cdk = require('@aws-cdk/core'); 2 | const { NodejsFunction } = require('aws-lambda-nodejs-esbuild'); 3 | 4 | class MinimalStack extends cdk.Stack { 5 | constructor(scope, id, props) { 6 | super(scope, id, props); 7 | 8 | new NodejsFunction(this, 'MinimalExampleFunction'); 9 | } 10 | } 11 | 12 | module.exports = { MinimalStack }; 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-lambda-nodejs-esbuild", 3 | "description": "λ💨 AWS CDK Construct to bundle JavaScript and TypeScript AWS lambdas using extremely fast esbuild", 4 | "version": "0.0.0-development", 5 | "license": "MIT", 6 | "author": "Victor Korzunin ", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "package.json", 12 | "LICENSE", 13 | "README.md" 14 | ], 15 | "scripts": { 16 | "prepublishOnly": "npm run build", 17 | "precommit": "npm run lint", 18 | "build": "npm run clean && tsc", 19 | "clean": "rm -rf ./dist", 20 | "pretest": "npm run lint", 21 | "test": "jest --passWithNoTests", 22 | "lint": "eslint .", 23 | "coverage": "npm run test -- --coverage" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/floydspace/aws-lambda-nodejs-esbuild.git" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/floydspace/aws-lambda-nodejs-esbuild/issues" 31 | }, 32 | "homepage": "https://floydspace.github.io/aws-lambda-nodejs-esbuild", 33 | "keywords": [ 34 | "aws-cdk", 35 | "cdk-construct", 36 | "construct", 37 | "esbuild", 38 | "aws-lambda", 39 | "aws-lambda-node", 40 | "aws", 41 | "lambda", 42 | "bundler", 43 | "builder", 44 | "typescript" 45 | ], 46 | "devDependencies": { 47 | "@aws-cdk/assert": "^1.114.0", 48 | "@aws-cdk/aws-lambda": "^1.114.0", 49 | "@aws-cdk/core": "^1.114.0", 50 | "@commitlint/cli": "^11.0.0", 51 | "@commitlint/config-conventional": "^11.0.0", 52 | "@types/fs-extra": "^9.0.2", 53 | "@types/jest": "^26.0.14", 54 | "@types/mock-fs": "^4.13.0", 55 | "@types/node": "^12.12.38", 56 | "@types/ramda": "^0.27.30", 57 | "@typescript-eslint/eslint-plugin": "^4.5.0", 58 | "@typescript-eslint/parser": "^4.5.0", 59 | "eslint": "^7.12.0", 60 | "husky": "^4.3.0", 61 | "jest": "^26.6.1", 62 | "mock-fs": "^4.13.0", 63 | "semantic-release": "^17.2.1", 64 | "ts-jest": "^26.4.0", 65 | "typescript": "^4.0.3" 66 | }, 67 | "dependencies": { 68 | "esbuild": ">=0.6", 69 | "fs-extra": "^9.0.1", 70 | "ramda": "^0.27.1" 71 | }, 72 | "peerDependencies": { 73 | "@aws-cdk/aws-lambda": "^1.0.0", 74 | "@aws-cdk/core": "^1.0.0" 75 | }, 76 | "publishConfig": { 77 | "access": "public" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/function.ts: -------------------------------------------------------------------------------- 1 | import * as lambda from '@aws-cdk/aws-lambda'; 2 | import * as cdk from '@aws-cdk/core'; 3 | import * as es from 'esbuild'; 4 | import * as path from 'path'; 5 | import { mergeRight, union, without } from 'ramda'; 6 | 7 | import { packExternalModules } from './pack-externals'; 8 | import { NodejsFunctionProps } from './props'; 9 | import { extractFileName, findProjectRoot, nodeMajorVersion } from './utils'; 10 | 11 | const BUILD_FOLDER = '.build'; 12 | const DEFAULT_BUILD_OPTIONS: es.BuildOptions = { 13 | bundle: true, 14 | target: `node${nodeMajorVersion()}`, 15 | }; 16 | 17 | const NodeMajorMap = { 18 | 8: lambda.Runtime.NODEJS_8_10, 19 | 9: lambda.Runtime.NODEJS_8_10, 20 | 10: lambda.Runtime.NODEJS_10_X, 21 | 11: lambda.Runtime.NODEJS_10_X, 22 | 12: lambda.Runtime.NODEJS_12_X, 23 | 13: lambda.Runtime.NODEJS_12_X, 24 | 14: lambda.Runtime.NODEJS_14_X, 25 | 15: lambda.Runtime.NODEJS_14_X, 26 | }; 27 | 28 | /** 29 | * A Node.js Lambda function bundled using `esbuild` 30 | */ 31 | export class NodejsFunction extends lambda.Function { 32 | constructor(scope: cdk.Construct, id: string, props: NodejsFunctionProps = {}) { 33 | if (props.runtime && props.runtime.family !== lambda.RuntimeFamily.NODEJS) { 34 | throw new Error('Only `NODEJS` runtimes are supported.'); 35 | } 36 | 37 | const projectRoot = findProjectRoot(props.rootDir); 38 | if (!projectRoot) { 39 | throw new Error('Cannot find root directory. Please specify it with `rootDir` option.'); 40 | } 41 | 42 | const withDefaultOptions = mergeRight(DEFAULT_BUILD_OPTIONS); 43 | const buildOptions = withDefaultOptions(props.esbuildOptions ?? {}); 44 | const exclude = props.exclude ?? ['aws-sdk']; 45 | const packager = props.packager ?? true; 46 | const handler = props.handler ?? 'index.handler'; 47 | const defaultRuntime = NodeMajorMap[nodeMajorVersion()]; 48 | const runtime = props.runtime ?? defaultRuntime; 49 | const entry = extractFileName(projectRoot, handler); 50 | 51 | es.buildSync({ 52 | ...buildOptions, 53 | external: union(exclude, buildOptions.external || []), 54 | entryPoints: [path.join(projectRoot, entry)], 55 | outdir: path.join(projectRoot, BUILD_FOLDER, path.dirname(entry)), 56 | platform: 'node', 57 | }); 58 | 59 | if (packager) { 60 | packExternalModules( 61 | without(exclude, buildOptions.external || []), 62 | projectRoot, 63 | path.join(projectRoot, BUILD_FOLDER), 64 | packager !== true ? packager : undefined 65 | ); 66 | } 67 | 68 | super(scope, id, { 69 | ...props, 70 | runtime, 71 | code: lambda.Code.fromAsset(path.join(projectRoot, BUILD_FOLDER)), 72 | handler, 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './function'; 2 | export * from './props'; 3 | -------------------------------------------------------------------------------- /src/pack-externals.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as path from 'path'; 3 | import { compose, forEach, head, includes, isEmpty, join, map, mergeRight, pick, replace, tail, toPairs, uniq } from 'ramda'; 4 | 5 | import * as Packagers from './packagers'; 6 | import { JSONObject } from './types'; 7 | 8 | function rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { 9 | if (/^(?:file:[^/]{2}|\.\/|\.\.\/)/.test(moduleVersion)) { 10 | const filePath = replace(/^file:/, '', moduleVersion); 11 | return replace( 12 | /\\/g, 13 | '/', 14 | `${moduleVersion.startsWith('file:') ? 'file:' : ''}${pathToPackageRoot}/${filePath}` 15 | ); 16 | } 17 | 18 | return moduleVersion; 19 | } 20 | 21 | /** 22 | * Add the given modules to a package json's dependencies. 23 | */ 24 | function addModulesToPackageJson(externalModules: string[], packageJson: JSONObject, pathToPackageRoot: string) { 25 | forEach(externalModule => { 26 | const splitModule = externalModule.split('@'); 27 | // If we have a scoped module we have to re-add the @ 28 | if (externalModule.startsWith('@')) { 29 | splitModule.splice(0, 1); 30 | splitModule[0] = '@' + splitModule[0]; 31 | } 32 | let moduleVersion = join('@', tail(splitModule)); 33 | // We have to rebase file references to the target package.json 34 | moduleVersion = rebaseFileReferences(pathToPackageRoot, moduleVersion); 35 | packageJson.dependencies = packageJson.dependencies || {}; 36 | packageJson.dependencies[head(splitModule) ?? ''] = moduleVersion; 37 | }, externalModules); 38 | } 39 | 40 | /** 41 | * Resolve the needed versions of production dependencies for external modules. 42 | */ 43 | function getProdModules(externalModules: { external: string }[], packageJsonPath: string) { 44 | // eslint-disable-next-line @typescript-eslint/no-var-requires 45 | const packageJson = require(packageJsonPath); 46 | const prodModules: string[] = []; 47 | 48 | // only process the module stated in dependencies section 49 | if (!packageJson.dependencies) { 50 | return []; 51 | } 52 | 53 | // Get versions of all transient modules 54 | forEach(externalModule => { 55 | const moduleVersion = packageJson.dependencies[externalModule.external]; 56 | 57 | if (moduleVersion) { 58 | prodModules.push(`${externalModule.external}@${moduleVersion}`); 59 | 60 | // Check if the module has any peer dependencies and include them too 61 | try { 62 | const modulePackagePath = path.join( 63 | path.dirname(packageJsonPath), 64 | 'node_modules', 65 | externalModule.external, 66 | 'package.json' 67 | ); 68 | const peerDependencies = require(modulePackagePath).peerDependencies as Record; 69 | if (!isEmpty(peerDependencies)) { 70 | const peerModules = getProdModules( 71 | compose(map(([external]) => ({ external })), toPairs)(peerDependencies), 72 | packageJsonPath 73 | ); 74 | Array.prototype.push.apply(prodModules, peerModules); 75 | } 76 | } catch (e) { 77 | console.log(`WARNING: Could not check for peer dependencies of ${externalModule.external}`); 78 | } 79 | } else { 80 | if (!packageJson.devDependencies || !packageJson.devDependencies[externalModule.external]) { 81 | prodModules.push(externalModule.external); 82 | } else { 83 | // To minimize the chance of breaking setups we whitelist packages available on AWS here. These are due to the previously missing check 84 | // most likely set in devDependencies and should not lead to an error now. 85 | const ignoredDevDependencies = ['aws-sdk']; 86 | 87 | if (!includes(externalModule.external, ignoredDevDependencies)) { 88 | // Runtime dependency found in devDependencies but not forcefully excluded 89 | throw new Error(`dependency error: ${externalModule.external}.`); 90 | } 91 | } 92 | } 93 | }, externalModules); 94 | 95 | return prodModules; 96 | } 97 | 98 | /** 99 | * We need a performant algorithm to install the packages for each single function. 100 | * (1) We fetch ALL packages needed by ALL functions in a first step 101 | * and use this as a base npm checkout. The checkout will be done to a 102 | * separate temporary directory with a package.json that contains everything. 103 | * (2) For each single compile we copy the whole node_modules to the compile 104 | * directory and create a (function) compile specific package.json and store 105 | * it in the compile directory. Now we start npm again there, and npm will just 106 | * remove the superfluous packages and optimize the remaining dependencies. 107 | * This will utilize the npm cache at its best and give us the needed results 108 | * and performance. 109 | */ 110 | export function packExternalModules(externals: string[], cwd: string, compositeModulePath: string, pkger?: 'npm' | 'yarn') { 111 | if (!externals || !externals.length) { 112 | return; 113 | } 114 | 115 | // Read function package.json 116 | const packageJsonPath = path.join(cwd, 'package.json'); 117 | 118 | // Determine and create packager 119 | const packager = Packagers.get(cwd, pkger); 120 | 121 | // Fetch needed original package.json sections 122 | const packageJson = fs.readJsonSync(packageJsonPath); 123 | const packageSections = pick(packager.copyPackageSectionNames, packageJson); 124 | 125 | // (1) Generate dependency composition 126 | const externalModules = externals.map(external => ({ external })); 127 | const compositeModules: JSONObject = uniq(getProdModules(externalModules, packageJsonPath)); 128 | 129 | if (isEmpty(compositeModules)) { 130 | // The compiled code does not reference any external modules at all 131 | return; 132 | } 133 | 134 | // (1.a) Install all needed modules 135 | const compositePackageJsonPath = path.join(compositeModulePath, 'package.json'); 136 | 137 | // (1.a.1) Create a package.json 138 | const compositePackageJson = mergeRight( 139 | { 140 | name: 'externals', 141 | version: '1.0.0', 142 | private: true, 143 | }, 144 | packageSections 145 | ); 146 | const relativePath = path.relative(compositeModulePath, path.dirname(packageJsonPath)); 147 | addModulesToPackageJson(compositeModules, compositePackageJson, relativePath); 148 | fs.writeJsonSync(compositePackageJsonPath, compositePackageJson); 149 | 150 | // (1.a.2) Copy package-lock.json if it exists, to prevent unwanted upgrades 151 | const packageLockPath = path.join(path.dirname(packageJsonPath), packager.lockfileName); 152 | 153 | if (fs.existsSync(packageLockPath)) { 154 | let packageLockFile = fs.readJsonSync(packageLockPath); 155 | packageLockFile = packager.rebaseLockfile(relativePath, packageLockFile); 156 | fs.writeJsonSync(path.join(compositeModulePath, packager.lockfileName), packageLockFile); 157 | } 158 | 159 | packager.install(compositeModulePath); 160 | 161 | // Prune extraneous packages - removes not needed ones 162 | packager.prune(compositeModulePath); 163 | } 164 | -------------------------------------------------------------------------------- /src/packagers/index.ts: -------------------------------------------------------------------------------- 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; 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 | import { getCurrentPackager, getPackagerFromLockfile } from '../utils'; 24 | 25 | const registeredPackagers = { 26 | npm: new NPM(), 27 | yarn: new Yarn() 28 | }; 29 | 30 | /** 31 | * Factory method. 32 | * @param {string} packagerId - Well known packager id. 33 | */ 34 | export function get(cwd: string, packagerId?: keyof typeof registeredPackagers): Packager { 35 | const pkger = findPackager(cwd, packagerId); 36 | 37 | if (!(pkger in registeredPackagers)) { 38 | const message = `Could not find packager '${pkger}'`; 39 | console.log(`ERROR: ${message}`); 40 | throw new Error(message); 41 | } 42 | 43 | return registeredPackagers[pkger]; 44 | } 45 | 46 | /** 47 | * Determine what package manager to use based on what preference is set, 48 | * and whether it's currently running in a yarn/npm script 49 | * 50 | * @export 51 | * @param {InstallConfig} config 52 | * @returns {SupportedPackageManagers} 53 | */ 54 | function findPackager(cwd: string, prefer?: keyof typeof registeredPackagers): keyof typeof registeredPackagers { 55 | let pkgManager: keyof typeof registeredPackagers | null = prefer || getCurrentPackager(); 56 | 57 | if (!pkgManager) { 58 | pkgManager = getPackagerFromLockfile(cwd); 59 | } 60 | 61 | if (!pkgManager) { 62 | for (const pkg in registeredPackagers) { 63 | if (registeredPackagers[pkg].isManagerInstalled(cwd)) { 64 | pkgManager = pkg as keyof typeof registeredPackagers; 65 | break; 66 | } 67 | } 68 | } 69 | 70 | if (!pkgManager) { 71 | throw new Error('No supported package manager found'); 72 | } 73 | 74 | return pkgManager; 75 | } 76 | -------------------------------------------------------------------------------- /src/packagers/npm.ts: -------------------------------------------------------------------------------- 1 | import { any, isEmpty } from 'ramda'; 2 | 3 | import { JSONObject } from '../types'; 4 | import { SpawnError, spawnProcess } from '../utils'; 5 | import { Packager } from './packager'; 6 | 7 | /** 8 | * NPM packager. 9 | */ 10 | export class NPM implements Packager { 11 | get lockfileName() { 12 | return 'package-lock.json'; 13 | } 14 | 15 | get copyPackageSectionNames() { 16 | return []; 17 | } 18 | 19 | get mustCopyModules() { 20 | return true; 21 | } 22 | 23 | isManagerInstalled(cwd: string) { 24 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 25 | const args = ['--version']; 26 | 27 | try { 28 | spawnProcess(command, args, { cwd }); 29 | return true; 30 | } catch (_e) { 31 | return false; 32 | } 33 | } 34 | 35 | getProdDependencies(cwd: string, depth?: number) { 36 | // Get first level dependency graph 37 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 38 | const args = [ 39 | 'ls', 40 | '-prod', // Only prod dependencies 41 | '-json', 42 | `-depth=${depth || 1}` 43 | ]; 44 | 45 | const ignoredNpmErrors = [ 46 | { npmError: 'extraneous', log: false }, 47 | { npmError: 'missing', log: false }, 48 | { npmError: 'peer dep missing', log: true } 49 | ]; 50 | 51 | let processOutput; 52 | try { 53 | processOutput = spawnProcess(command, args, { cwd }); 54 | } catch (err) { 55 | if (!(err instanceof SpawnError)) { 56 | throw err; 57 | } 58 | 59 | // Only exit with an error if we have critical npm errors for 2nd level inside 60 | const errors = err.stderr?.split('\n') ?? []; 61 | const failed = errors.reduce((f, error) => { 62 | if (f) { 63 | return true; 64 | } 65 | return ( 66 | !isEmpty(error) && 67 | !any(ignoredError => error.startsWith(`npm ERR! ${ignoredError.npmError}`), ignoredNpmErrors) 68 | ); 69 | }, false); 70 | 71 | if (failed || isEmpty(err.stdout)) { 72 | throw err; 73 | } 74 | 75 | processOutput = { stdout: err.stdout }; 76 | } 77 | 78 | return JSON.parse(processOutput.stdout); 79 | } 80 | 81 | /** 82 | * We should not be modifying 'package-lock.json' 83 | * because this file should be treated as internal to npm. 84 | * 85 | * Rebase package-lock is a temporary workaround and must be 86 | * removed as soon as https://github.com/npm/npm/issues/19183 gets fixed. 87 | */ 88 | rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject) { 89 | if (lockfile.version) { 90 | lockfile.version = this.rebaseFileReferences(pathToPackageRoot, lockfile.version); 91 | } 92 | 93 | if (lockfile.dependencies) { 94 | for (const lockedDependency in lockfile.dependencies) { 95 | this.rebaseLockfile(pathToPackageRoot, lockfile.dependencies[lockedDependency]); 96 | } 97 | } 98 | 99 | return lockfile; 100 | } 101 | 102 | install(cwd) { 103 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 104 | const args = ['install']; 105 | 106 | spawnProcess(command, args, { cwd }); 107 | } 108 | 109 | prune(cwd) { 110 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 111 | const args = ['prune']; 112 | 113 | spawnProcess(command, args, { cwd }); 114 | } 115 | 116 | runScripts(cwd, scriptNames) { 117 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 118 | 119 | scriptNames.forEach(scriptName => spawnProcess(command, ['run', scriptName], { cwd })); 120 | } 121 | 122 | private rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { 123 | if (/^file:[^/]{2}/.test(moduleVersion)) { 124 | const filePath = moduleVersion.replace(/^file:/, ''); 125 | return `file:${pathToPackageRoot}/${filePath}`.replace(/\\/g, '/'); 126 | } 127 | 128 | return moduleVersion; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/packagers/packager.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from '../types'; 2 | 3 | export interface Packager { 4 | lockfileName: string; 5 | copyPackageSectionNames: Array; 6 | mustCopyModules: boolean; 7 | isManagerInstalled(cwd: string): boolean; 8 | getProdDependencies(cwd: string, depth: number): JSONObject; 9 | rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject): void; 10 | install(cwd: string): void; 11 | prune(cwd: string): void; 12 | runScripts(cwd: string, scriptNames): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/packagers/yarn.ts: -------------------------------------------------------------------------------- 1 | import { any, head, isEmpty, join, pathOr, split, tail } from 'ramda'; 2 | 3 | import { JSONObject } from '../types'; 4 | import { safeJsonParse, SpawnError, spawnProcess, splitLines } from '../utils'; 5 | import { Packager } from './packager'; 6 | 7 | /** 8 | * Yarn packager. 9 | * 10 | * Yarn specific packagerOptions (default): 11 | * flat (false) - Use --flat with install 12 | * ignoreScripts (false) - Do not execute scripts during install 13 | */ 14 | export class Yarn implements Packager { 15 | get lockfileName() { 16 | return 'yarn.lock'; 17 | } 18 | 19 | get copyPackageSectionNames() { 20 | return ['resolutions']; 21 | } 22 | 23 | get mustCopyModules() { 24 | return false; 25 | } 26 | 27 | isManagerInstalled(cwd: string) { 28 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 29 | const args = ['--version']; 30 | 31 | try { 32 | spawnProcess(command, args, { cwd }); 33 | return true; 34 | } catch (_e) { 35 | return false; 36 | } 37 | } 38 | 39 | getProdDependencies(cwd: string, depth?: number) { 40 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 41 | const args = ['list', `--depth=${depth || 1}`, '--json', '--production']; 42 | 43 | // If we need to ignore some errors add them here 44 | const ignoredYarnErrors: {npmError: string}[] = []; 45 | 46 | let processOutput; 47 | try { 48 | processOutput = spawnProcess(command, args, { cwd }); 49 | } catch (err) { 50 | if (!(err instanceof SpawnError)) { 51 | throw err; 52 | } 53 | 54 | // Only exit with an error if we have critical npm errors for 2nd level inside 55 | const errors = err.stderr?.split('\n') ?? []; 56 | const failed = errors.reduce((f, error) => { 57 | if (f) { 58 | return true; 59 | } 60 | return ( 61 | !isEmpty(error) && 62 | !any(ignoredError => error.startsWith(`npm ERR! ${ignoredError.npmError}`), ignoredYarnErrors) 63 | ); 64 | }, false); 65 | 66 | if (failed || isEmpty(err.stdout)) { 67 | throw err; 68 | } 69 | 70 | processOutput = { stdout: err.stdout }; 71 | } 72 | 73 | const lines = splitLines(processOutput.stdout); 74 | const parsedLines = lines.map(safeJsonParse); 75 | const parsedTree = parsedLines.find(line => line && line.type === 'tree'); 76 | const convertTrees = ts => ts.reduce((__, tree: JSONObject) => { 77 | const splitModule = split('@', tree.name); 78 | // If we have a scoped module we have to re-add the @ 79 | if (tree.name.startsWith('@')) { 80 | splitModule.splice(0, 1); 81 | splitModule[0] = '@' + splitModule[0]; 82 | } 83 | __[head(splitModule) ?? ''] = { 84 | version: join('@', tail(splitModule)), 85 | dependencies: convertTrees(tree.children) 86 | }; 87 | return __; 88 | }, {}); 89 | 90 | const trees = pathOr([], ['data', 'trees'], parsedTree); 91 | const result = { 92 | problems: [], 93 | dependencies: convertTrees(trees) 94 | }; 95 | return result; 96 | } 97 | 98 | rebaseLockfile(pathToPackageRoot, lockfile) { 99 | const fileVersionMatcher = /[^"/]@(?:file:)?((?:\.\/|\.\.\/).*?)[":,]/gm; 100 | const replacements: {oldRef: string, newRef: string}[] = []; 101 | let match; 102 | 103 | // Detect all references and create replacement line strings 104 | while ((match = fileVersionMatcher.exec(lockfile)) !== null) { 105 | replacements.push({ 106 | oldRef: match[1], 107 | newRef: `${pathToPackageRoot}/${match[1]}`.replace(/\\/g, '/') 108 | }); 109 | } 110 | 111 | // Replace all lines in lockfile 112 | return replacements.reduce((__, replacement) => __.replace(replacement.oldRef, replacement.newRef), lockfile); 113 | } 114 | 115 | install(cwd: string, packagerOptions?) { 116 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 117 | const args = [ 'install', '--frozen-lockfile', '--non-interactive' ]; 118 | 119 | // Convert supported packagerOptions 120 | if (packagerOptions?.ignoreScripts) { 121 | args.push('--ignore-scripts'); 122 | } 123 | 124 | spawnProcess(command, args, { cwd }); 125 | } 126 | 127 | // "Yarn install" prunes automatically 128 | prune(cwd: string, packagerOptions?) { 129 | return this.install(cwd, packagerOptions); 130 | } 131 | 132 | runScripts(cwd, scriptNames: string[]) { 133 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 134 | 135 | scriptNames.forEach(scriptName => spawnProcess(command, ['run', scriptName], { cwd })); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/props.ts: -------------------------------------------------------------------------------- 1 | import * as lambda from '@aws-cdk/aws-lambda'; 2 | import { BuildOptions } from 'esbuild'; 3 | 4 | /** 5 | * Properties for a NodejsFunction 6 | */ 7 | export interface NodejsFunctionProps extends lambda.FunctionOptions { 8 | /** 9 | * The root of the lambda project. If you specify this prop, ensure that 10 | * this path includes `entry` and any module/dependencies used by your 11 | * function otherwise bundling will not be possible. 12 | * 13 | * @default = the closest path containing a .git folder 14 | */ 15 | readonly rootDir?: string; 16 | 17 | /** 18 | * The name of the method within your code that Lambda calls to execute your function. 19 | * 20 | * The format includes the file name and handler function. 21 | * For more information, see https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html. 22 | * 23 | * @default = 'index.handler' 24 | */ 25 | readonly handler?: string; 26 | 27 | /** 28 | * The runtime environment. Only runtimes of the Node.js family are 29 | * supported. 30 | * 31 | * @default = `NODEJS_12_X` if `process.versions.node` >= '12.0.0', 32 | * `NODEJS_10_X` otherwise. 33 | */ 34 | readonly runtime?: lambda.Runtime; 35 | 36 | /** 37 | * The list of modules that must be excluded from bundle and from externals. 38 | * 39 | * @default = ['aws-sdk'] 40 | */ 41 | readonly exclude?: string[]; 42 | 43 | /** 44 | * Whether to use package manager to pack external modules or explicit name of a well known packager. 45 | * 46 | * @default = true // Determined based on what preference is set, and whether it's currently running in a yarn/npm script 47 | */ 48 | readonly packager?: Packager | boolean; 49 | 50 | /** 51 | * The esbuild bundler specific options. 52 | * 53 | * @default = NodeMajorESMap { 54 | * 8 : 'es2016' 55 | * 9 : 'es2017' 56 | * 10: 'es2018' 57 | * 11: 'es2018' 58 | * 12: 'es2019' 59 | * 13: 'es2019' 60 | * 14: 'es2020' 61 | * 15: 'es2020' 62 | * >=16: 'esnext' 63 | * }; 64 | */ 65 | readonly esbuildOptions?: BuildOptions; 66 | } 67 | 68 | /** 69 | * Package manager to pack external modules. 70 | */ 71 | export enum Packager { 72 | NPM = 'npm', 73 | YARN = 'yarn', 74 | } 75 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export type JSONObject = any; 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as childProcess from 'child_process'; 2 | import * as fs from 'fs-extra'; 3 | import * as path from 'path'; 4 | import { join } from 'ramda'; 5 | 6 | export class SpawnError extends Error { 7 | constructor(message: string, public stdout: string, public stderr: string) { 8 | super(message); 9 | } 10 | 11 | toString() { 12 | return `${this.message}\n${this.stderr}`; 13 | } 14 | } 15 | 16 | /** 17 | * Executes a child process without limitations on stdout and stderr. 18 | * On error (exit code is not 0), it rejects with a SpawnProcessError that contains the stdout and stderr streams, 19 | * on success it returns the streams in an object. 20 | * @param {string} command - Command 21 | * @param {string[]} [args] - Arguments 22 | * @param {Object} [options] - Options for child_process.spawn 23 | */ 24 | export function spawnProcess(command: string, args: string[], options: childProcess.SpawnOptionsWithoutStdio) { 25 | const child = childProcess.spawnSync(command, args, options); 26 | const stdout = child.stdout?.toString('utf8'); 27 | const stderr = child.stderr?.toString('utf8'); 28 | 29 | if (child.status !== 0) { 30 | throw new SpawnError(`${command} ${join(' ', args)} failed with code ${child.status}`, stdout, stderr); 31 | } 32 | 33 | return { stdout, stderr }; 34 | } 35 | 36 | export function safeJsonParse(str: string) { 37 | try { 38 | return JSON.parse(str); 39 | } catch (e) { 40 | return null; 41 | } 42 | } 43 | 44 | export function splitLines(str: string) { 45 | return str.split(/\r?\n/); 46 | } 47 | 48 | /** 49 | * Extracts the file name from handler string. 50 | */ 51 | export function extractFileName(cwd: string, handler: string): string { 52 | const fnName = path.extname(handler); 53 | const fnNameLastAppearanceIndex = handler.lastIndexOf(fnName); 54 | // replace only last instance to allow the same name for file and handler 55 | const fileName = handler.substring(0, fnNameLastAppearanceIndex); 56 | 57 | // Check if the .ts files exists. If so return that to watch 58 | if (fs.existsSync(path.join(cwd, fileName + '.ts'))) { 59 | return fileName + '.ts'; 60 | } 61 | 62 | // Check if the .js files exists. If so return that to watch 63 | if (fs.existsSync(path.join(cwd, fileName + '.js'))) { 64 | return fileName + '.js'; 65 | } 66 | 67 | // Can't find the files. Watch will have an exception anyway. So throw one with error. 68 | console.log(`Cannot locate handler - ${fileName} not found`); 69 | throw new Error('Compilation failed. Please ensure handler exists with ext .ts or .js'); 70 | } 71 | 72 | /** 73 | * Find a file by walking up parent directories 74 | */ 75 | export function findUp(name: string, directory: string = process.cwd()): string | undefined { 76 | const absoluteDirectory = path.resolve(directory); 77 | 78 | if (fs.existsSync(path.join(directory, name))) { 79 | return directory; 80 | } 81 | 82 | const { root } = path.parse(absoluteDirectory); 83 | if (absoluteDirectory === root) { 84 | return undefined; 85 | } 86 | 87 | return findUp(name, path.dirname(absoluteDirectory)); 88 | } 89 | 90 | /** 91 | * Forwards `rootDir` or finds project root folder. 92 | */ 93 | export function findProjectRoot(rootDir?: string): string | undefined { 94 | return ( 95 | rootDir ?? findUp('yarn.lock') ?? findUp('package-lock.json') ?? findUp('package.json') ?? findUp(`.git${path.sep}`) 96 | ); 97 | } 98 | 99 | /** 100 | * Returns the major version of node installation 101 | */ 102 | export function nodeMajorVersion(): number { 103 | return parseInt(process.versions.node.split('.')[0], 10); 104 | } 105 | 106 | /** 107 | * Returns the package manager currently active if the program is executed 108 | * through an npm or yarn script like: 109 | * ```bash 110 | * yarn run example 111 | * npm run example 112 | * ``` 113 | */ 114 | export function getCurrentPackager() { 115 | const userAgent = process.env.npm_config_user_agent; 116 | if (!userAgent) { 117 | return null; 118 | } 119 | 120 | if (userAgent.startsWith('npm')) { 121 | return 'npm'; 122 | } 123 | 124 | if (userAgent.startsWith('yarn')) { 125 | return 'yarn'; 126 | } 127 | 128 | return null; 129 | } 130 | 131 | /** 132 | * Checks for the presence of package-lock.json or yarn.lock to determine which package manager is being used 133 | */ 134 | export function getPackagerFromLockfile(cwd: string) { 135 | if (fs.existsSync(path.join(cwd, 'package-lock.json'))) { 136 | return 'npm'; 137 | } 138 | 139 | if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { 140 | return 'yarn'; 141 | } 142 | 143 | return null; 144 | } 145 | -------------------------------------------------------------------------------- /tests/function.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('esbuild'); 2 | jest.mock('../src/pack-externals'); 3 | 4 | import '@aws-cdk/assert/jest'; 5 | 6 | import { Runtime, RuntimeFamily } from '@aws-cdk/aws-lambda'; 7 | import { Stack } from '@aws-cdk/core'; 8 | import { buildSync } from 'esbuild'; 9 | import mockfs from 'mock-fs'; 10 | import path, { join } from 'path'; 11 | 12 | import { NodejsFunction } from '../src'; 13 | 14 | 15 | describe('NodejsFunction tests', () => { 16 | describe('with valid folder structure', () => { 17 | beforeAll(() => { 18 | mockfs({ 19 | 'index.ts': '', 20 | 'source/index.ts': '', 21 | 'main.ts': '', 22 | 'a/b/c.ts': '', 23 | 'src': { 24 | 'index.ts': '', 25 | '.build': {} 26 | }, 27 | 'package-lock.json': '', 28 | '.build': {} 29 | }); 30 | }); 31 | 32 | beforeEach(() => { 33 | (buildSync as jest.Mock).mockReset(); 34 | }); 35 | 36 | it.each(Runtime.ALL.filter(r => r.family !== RuntimeFamily.NODEJS))('Should be thrown due to not supported runtime', (runtime) => { 37 | const constructor = () => new NodejsFunction(new Stack(), 'lambda-function', { runtime }); 38 | expect(constructor).toThrowError(/^Only `NODEJS` runtimes are supported.$/); 39 | }); 40 | 41 | it('Should not be thrown', () => { 42 | const stack = new Stack(); 43 | const constructor = () => new NodejsFunction(stack, 'lambda-function'); 44 | expect(constructor).not.toThrow(); 45 | expect(buildSync).toBeCalledTimes(1); 46 | expect(stack).toHaveResource('AWS::Lambda::Function', { 47 | Handler: 'index.handler', 48 | }); 49 | }); 50 | 51 | it.each` 52 | handler | entry 53 | ${null} | ${'index.ts'} 54 | ${'source/index.handler'} | ${'source/index.ts'} 55 | ${'main.func'} | ${'main.ts'} 56 | ${'a/b/c.h'} | ${'a/b/c.ts'} 57 | `('Should be valid entry with default rootDir', ({ handler, entry }) => { 58 | new NodejsFunction(new Stack(), 'lambda-function', { handler }); 59 | expect(buildSync).toHaveBeenCalledWith(expect.objectContaining({ entryPoints: [join(process.cwd(), entry)] })); 60 | }); 61 | 62 | it('Should be valid outdir with custom rootDir', () => { 63 | new NodejsFunction(new Stack(), 'lambda-function', { rootDir: path.join(__dirname, '../src') }); 64 | expect(buildSync).toHaveBeenCalledWith(expect.objectContaining({ outdir: path.join(__dirname, '../src', '.build') })); 65 | }); 66 | 67 | afterAll(() => { 68 | mockfs.restore(); 69 | }); 70 | }); 71 | 72 | describe('with invalid folder structure', () => { 73 | beforeAll(() => { 74 | mockfs(); 75 | }); 76 | 77 | it('Should be thrown due to unrecognised root directory', () => { 78 | const constructor = () => new NodejsFunction(new Stack(), 'lambda-function'); 79 | expect(constructor).toThrowError(/^Cannot find root directory./); 80 | }); 81 | 82 | afterAll(() => { 83 | mockfs.restore(); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/packagers/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for packagers/index 3 | */ 4 | 5 | import { get } from '../../src/packagers'; 6 | import { NPM } from '../../src/packagers/npm'; 7 | import * as Utils from '../../src/utils'; 8 | 9 | const getCurrentPackager = jest.spyOn(Utils, 'getCurrentPackager'); 10 | 11 | describe('packagers factory', () => { 12 | it('should throw on unknown packagers', () => { 13 | getCurrentPackager.mockReset().mockReturnValue('unknown' as never); 14 | expect(() => get('.')).toThrowError(/Could not find packager 'unknown'/); 15 | }); 16 | 17 | it('should return npm packager', () => { 18 | const npm = get('.', 'npm'); 19 | expect(npm).toBeInstanceOf(NPM); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/packagers/npm.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for packagers/npm 3 | */ 4 | 5 | import { join } from 'ramda'; 6 | import { NPM } from '../../src/packagers/npm'; 7 | import * as Utils from '../../src/utils'; 8 | 9 | const spawnProcess = jest.spyOn(Utils, 'spawnProcess'); 10 | 11 | describe('npm', () => { 12 | let npmModule: NPM; 13 | 14 | beforeAll(() => { 15 | npmModule = new NPM(); 16 | }); 17 | 18 | it('should return "package-lock.json" as lockfile name', () => { 19 | expect(npmModule.lockfileName).toEqual('package-lock.json'); 20 | }); 21 | 22 | it('should return no packager sections', () => { 23 | expect(npmModule.copyPackageSectionNames).toEqual([]); 24 | }); 25 | 26 | it('requires to copy modules', () => { 27 | expect(npmModule.mustCopyModules).toBe(true); 28 | }); 29 | 30 | describe('install', () => { 31 | it('should use npm install', () => { 32 | spawnProcess.mockReset().mockReturnValue({ stdout: 'installed successfully', stderr: '' }); 33 | 34 | const result = npmModule.install('myPath'); 35 | 36 | expect(result).toBeUndefined(); 37 | expect(spawnProcess).toHaveBeenCalledTimes(1); 38 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), ['install'], { 39 | cwd: 'myPath' 40 | }); 41 | }); 42 | }); 43 | 44 | describe('prune', () => { 45 | it('should use npm prune', () => { 46 | spawnProcess.mockReset().mockReturnValue({ stdout: 'success', stderr: '' }); 47 | 48 | const result = npmModule.prune('myPath'); 49 | 50 | expect(result).toBeUndefined(); 51 | expect(spawnProcess).toHaveBeenCalledTimes(1); 52 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), ['prune'], { 53 | cwd: 'myPath' 54 | }); 55 | }); 56 | }); 57 | 58 | describe('runScripts', () => { 59 | it('should use npm run for the given scripts', () => { 60 | spawnProcess.mockReset().mockReturnValue({ stdout: 'success', stderr: '' }); 61 | 62 | const result = npmModule.runScripts('myPath', ['s1', 's2']); 63 | 64 | expect(result).toBeUndefined(); 65 | expect(spawnProcess).toHaveBeenCalledTimes(2); 66 | expect(spawnProcess).toHaveBeenNthCalledWith(1, expect.stringMatching(/^npm/), ['run', 's1'], { 67 | cwd: 'myPath' 68 | }); 69 | expect(spawnProcess).toHaveBeenNthCalledWith(2, expect.stringMatching(/^npm/), ['run', 's2'], { 70 | cwd: 'myPath' 71 | }); 72 | }); 73 | }); 74 | 75 | describe('getProdDependencies', () => { 76 | it('should use npm ls', () => { 77 | spawnProcess.mockReset().mockReturnValue({ stdout: '{}', stderr: '' }); 78 | 79 | const result = npmModule.getProdDependencies('myPath', 10); 80 | 81 | expect(result).toEqual({}); 82 | expect(spawnProcess).toHaveBeenCalledTimes(1); 83 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ 84 | 'ls', 85 | '-prod', 86 | '-json', 87 | '-depth=10' 88 | ], { 89 | cwd: 'myPath' 90 | }); 91 | }); 92 | 93 | it('should default to depth 1', () => { 94 | spawnProcess.mockReset().mockReturnValue({ stdout: '{}', stderr: '' }); 95 | 96 | const result = npmModule.getProdDependencies('myPath'); 97 | 98 | expect(result).toEqual({}); 99 | expect(spawnProcess).toHaveBeenCalledTimes(1); 100 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ 101 | 'ls', 102 | '-prod', 103 | '-json', 104 | '-depth=1' 105 | ], { 106 | cwd: 'myPath' 107 | }); 108 | }); 109 | }); 110 | 111 | it('should reject if npm returns critical and minor errors', () => { 112 | const stderr = 113 | 'ENOENT: No such file\nnpm ERR! extraneous: sinon@2.3.8 ./babel-dynamically-entries/node_modules/serverless-webpack/node_modules/sinon\n\n'; 114 | spawnProcess.mockReset().mockImplementation(() => { 115 | throw new Utils.SpawnError('Command execution failed', '{}', stderr); 116 | }); 117 | 118 | const func = () => npmModule.getProdDependencies('myPath', 1); 119 | 120 | expect(func).toThrowError('Command execution failed'); 121 | // npm ls and npm prune should have been called 122 | expect(spawnProcess).toHaveBeenCalledTimes(1); 123 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ 124 | 'ls', 125 | '-prod', 126 | '-json', 127 | '-depth=1' 128 | ], { 129 | cwd: 'myPath' 130 | }); 131 | }); 132 | 133 | it('should reject if an error happens without any information in stdout', () => { 134 | spawnProcess.mockReset().mockImplementation(() => { 135 | throw new Utils.SpawnError('Command execution failed', '', ''); 136 | }); 137 | 138 | const func = () => npmModule.getProdDependencies('myPath', 1); 139 | 140 | expect(func).toThrowError('Command execution failed'); 141 | // npm ls and npm prune should have been called 142 | expect(spawnProcess).toHaveBeenCalledTimes(1); 143 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ 144 | 'ls', 145 | '-prod', 146 | '-json', 147 | '-depth=1' 148 | ], { 149 | cwd: 'myPath' 150 | }); 151 | }); 152 | 153 | it('should ignore minor local NPM errors and log them', () => { 154 | const stderr = join('\n', [ 155 | 'npm ERR! extraneous: sinon@2.3.8 ./babel-dynamically-entries/node_modules/serverless-webpack/node_modules/sinon', 156 | 'npm ERR! missing: internalpackage-1@1.0.0, required by internalpackage-2@1.0.0', 157 | 'npm ERR! peer dep missing: sinon@2.3.8' 158 | ]); 159 | const lsResult = { 160 | version: '1.0.0', 161 | problems: [ 162 | 'npm ERR! extraneous: sinon@2.3.8 ./babel-dynamically-entries/node_modules/serverless-webpack/node_modules/sinon', 163 | 'npm ERR! missing: internalpackage-1@1.0.0, required by internalpackage-2@1.0.0', 164 | 'npm ERR! peer dep missing: sinon@2.3.8' 165 | ], 166 | dependencies: { 167 | '@scoped/vendor': '1.0.0', 168 | uuid: '^5.4.1', 169 | bluebird: '^3.4.0' 170 | } 171 | }; 172 | 173 | spawnProcess.mockReset().mockImplementation(() => { 174 | throw new Utils.SpawnError('Command execution failed', JSON.stringify(lsResult), stderr); 175 | }); 176 | 177 | const dependencies = npmModule.getProdDependencies('myPath', 1); 178 | 179 | // npm ls and npm prune should have been called 180 | expect(spawnProcess).toHaveBeenCalledTimes(1); 181 | expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ 182 | 'ls', 183 | '-prod', 184 | '-json', 185 | '-depth=1' 186 | ], { 187 | cwd: 'myPath' 188 | }); 189 | expect(dependencies).toEqual(lsResult); 190 | }); 191 | 192 | it('should rebase lock file references', () => { 193 | const expectedLocalModule = 'file:../../locals/../../mymodule'; 194 | const fakePackageLockJSON = { 195 | name: 'test-service', 196 | version: '1.0.0', 197 | description: 'Packaged externals for test-service', 198 | private: true, 199 | dependencies: { 200 | '@scoped/vendor': '1.0.0', 201 | uuid: { 202 | version: '^5.4.1' 203 | }, 204 | bluebird: { 205 | version: '^3.4.0' 206 | }, 207 | localmodule: { 208 | version: 'file:../../mymodule' 209 | } 210 | } 211 | }; 212 | const expectedPackageLockJSON = { 213 | name: 'test-service', 214 | version: '1.0.0', 215 | description: 'Packaged externals for test-service', 216 | private: true, 217 | dependencies: { 218 | '@scoped/vendor': '1.0.0', 219 | uuid: { 220 | version: '^5.4.1' 221 | }, 222 | bluebird: { 223 | version: '^3.4.0' 224 | }, 225 | localmodule: { 226 | version: expectedLocalModule 227 | } 228 | } 229 | }; 230 | 231 | expect(npmModule.rebaseLockfile('../../locals', fakePackageLockJSON)).toEqual(expectedPackageLockJSON); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /tests/packagers/yarn.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for packagers/yarn 3 | */ 4 | 5 | import { Yarn } from '../../src/packagers/yarn'; 6 | import * as Utils from '../../src/utils'; 7 | 8 | const spawnProcess = jest.spyOn(Utils, 'spawnProcess'); 9 | 10 | describe('yarn', () => { 11 | let yarnModule: Yarn; 12 | 13 | beforeAll(() => { 14 | yarnModule = new Yarn(); 15 | }); 16 | 17 | it('should return "yarn.lock" as lockfile name', () => { 18 | expect(yarnModule.lockfileName).toEqual('yarn.lock'); 19 | }); 20 | 21 | it('should return packager sections', () => { 22 | expect(yarnModule.copyPackageSectionNames).toEqual(['resolutions']); 23 | }); 24 | 25 | it('does not require to copy modules', () => { 26 | expect(yarnModule.mustCopyModules).toBe(false); 27 | }); 28 | 29 | describe('getProdDependencies', () => { 30 | it('should use yarn list', () => { 31 | spawnProcess.mockReset().mockReturnValue({ stdout: '{}', stderr: '' }); 32 | 33 | const result = yarnModule.getProdDependencies('myPath', 1); 34 | 35 | expect(result).toBeTruthy(); 36 | expect(spawnProcess).toHaveBeenCalledTimes(1), 37 | expect(spawnProcess).toHaveBeenCalledWith( 38 | expect.stringMatching(/^yarn/), 39 | [ 'list', '--depth=1', '--json', '--production' ], 40 | { cwd: 'myPath' } 41 | ); 42 | }); 43 | 44 | it('should transform yarn trees to npm dependencies', () => { 45 | const testYarnResult = 46 | '{"type":"activityStart","data":{"id":0}}\n' + 47 | '{"type":"activityTick","data":{"id":0,"name":"archiver@^2.1.1"}}\n' + 48 | '{"type":"activityTick","data":{"id":0,"name":"bluebird@^3.5.1"}}\n' + 49 | '{"type":"activityTick","data":{"id":0,"name":"fs-extra@^4.0.3"}}\n' + 50 | '{"type":"activityTick","data":{"id":0,"name":"mkdirp@^0.5.1"}}\n' + 51 | '{"type":"activityTick","data":{"id":0,"name":"minimist@^0.0.8"}}\n' + 52 | '{"type":"activityTick","data":{"id":0,"name":"@sls/webpack@^1.0.0"}}\n' + 53 | '{"type":"tree","data":{"type":"list","trees":[' + 54 | '{"name":"archiver@2.1.1","children":[],"hint":null,"color":"bold",' + 55 | '"depth":0},{"name":"bluebird@3.5.1","children":[],"hint":null,"color":' + 56 | '"bold","depth":0},{"name":"fs-extra@4.0.3","children":[],"hint":null,' + 57 | '"color":"bold","depth":0},{"name":"mkdirp@0.5.1","children":[{"name":' + 58 | '"minimist@0.0.8","children":[],"hint":null,"color":"bold","depth":0}],' + 59 | '"hint":null,"color":null,"depth":0},{"name":"@sls/webpack@1.0.0",' + 60 | '"children":[],"hint":null,"color":"bold","depth":0}]}}\n'; 61 | const expectedResult = { 62 | problems: [], 63 | dependencies: { 64 | archiver: { 65 | version: '2.1.1', 66 | dependencies: {} 67 | }, 68 | bluebird: { 69 | version: '3.5.1', 70 | dependencies: {} 71 | }, 72 | 'fs-extra': { 73 | version: '4.0.3', 74 | dependencies: {} 75 | }, 76 | mkdirp: { 77 | version: '0.5.1', 78 | dependencies: { 79 | minimist: { 80 | version: '0.0.8', 81 | dependencies: {} 82 | } 83 | } 84 | }, 85 | '@sls/webpack': { 86 | version: '1.0.0', 87 | dependencies: {} 88 | } 89 | } 90 | }; 91 | spawnProcess.mockReset().mockReturnValue({ stdout: testYarnResult, stderr: '' }); 92 | 93 | const result = yarnModule.getProdDependencies('myPath', 1); 94 | 95 | expect(result).toEqual(expectedResult); 96 | }); 97 | 98 | it('should reject on critical yarn errors', () => { 99 | spawnProcess.mockReset().mockImplementation(() => { 100 | throw new Utils.SpawnError('Exited with code 1', '', 'Yarn failed.\nerror Could not find module.'); 101 | }); 102 | 103 | const func = () => yarnModule.getProdDependencies('myPath', 1); 104 | 105 | expect(func).toThrowError('Exited with code 1'); 106 | }); 107 | }); 108 | 109 | describe('rebaseLockfile', () => { 110 | it('should return the original lockfile', () => { 111 | const testContent = 'eugfogfoigqwoeifgoqwhhacvaisvciuviwefvc'; 112 | const testContent2 = 'eugfogfoigqwoeifgoqwhhacvaisvciuviwefvc'; 113 | expect(yarnModule.rebaseLockfile('.', testContent)).toEqual(testContent2); 114 | }); 115 | 116 | it('should rebase file references', () => { 117 | const testContent = ` 118 | acorn@^2.1.0, acorn@^2.4.0: 119 | version "2.7.0" 120 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" 121 | 122 | acorn@^3.0.4: 123 | version "3.3.0" 124 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" 125 | 126 | otherModule@file:../../otherModule/the-new-version: 127 | version "1.2.0" 128 | 129 | acorn@^2.1.0, acorn@^2.4.0: 130 | version "2.7.0" 131 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" 132 | 133 | "@myCompany/myModule@../../myModule/the-new-version": 134 | version "6.1.0" 135 | dependencies: 136 | aws-xray-sdk "^1.1.6" 137 | aws4 "^1.6.0" 138 | base-x "^3.0.3" 139 | bluebird "^3.5.1" 140 | chalk "^1.1.3" 141 | cls-bluebird "^2.1.0" 142 | continuation-local-storage "^3.2.1" 143 | lodash "^4.17.4" 144 | moment "^2.20.0" 145 | redis "^2.8.0" 146 | request "^2.83.0" 147 | ulid "^0.1.0" 148 | uuid "^3.1.0" 149 | 150 | acorn@^5.0.0, acorn@^5.5.0: 151 | version "5.5.3" 152 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" 153 | `; 154 | 155 | const expectedContent = ` 156 | acorn@^2.1.0, acorn@^2.4.0: 157 | version "2.7.0" 158 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" 159 | 160 | acorn@^3.0.4: 161 | version "3.3.0" 162 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" 163 | 164 | otherModule@file:../../project/../../otherModule/the-new-version: 165 | version "1.2.0" 166 | 167 | acorn@^2.1.0, acorn@^2.4.0: 168 | version "2.7.0" 169 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" 170 | 171 | "@myCompany/myModule@../../project/../../myModule/the-new-version": 172 | version "6.1.0" 173 | dependencies: 174 | aws-xray-sdk "^1.1.6" 175 | aws4 "^1.6.0" 176 | base-x "^3.0.3" 177 | bluebird "^3.5.1" 178 | chalk "^1.1.3" 179 | cls-bluebird "^2.1.0" 180 | continuation-local-storage "^3.2.1" 181 | lodash "^4.17.4" 182 | moment "^2.20.0" 183 | redis "^2.8.0" 184 | request "^2.83.0" 185 | ulid "^0.1.0" 186 | uuid "^3.1.0" 187 | 188 | acorn@^5.0.0, acorn@^5.5.0: 189 | version "5.5.3" 190 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" 191 | `; 192 | 193 | expect(yarnModule.rebaseLockfile('../../project', testContent)).toEqual(expectedContent); 194 | }); 195 | }); 196 | 197 | describe('install', () => { 198 | it('should use yarn install', () => { 199 | spawnProcess.mockReset().mockReturnValue({ stdout: 'installed successfully', stderr: '' }); 200 | 201 | const result = yarnModule.install('myPath', {}); 202 | 203 | expect(result).toBeUndefined(); 204 | expect(spawnProcess).toHaveBeenCalledTimes(1); 205 | expect(spawnProcess).toHaveBeenCalledWith( 206 | expect.stringMatching(/^yarn/), 207 | [ 'install', '--frozen-lockfile', '--non-interactive' ], 208 | { 209 | cwd: 'myPath' 210 | } 211 | ); 212 | }); 213 | 214 | it('should use ignoreScripts option', () => { 215 | spawnProcess.mockReset().mockReturnValue({ stdout: 'installed successfully', stderr: '' }); 216 | 217 | const result = yarnModule.install('myPath', { ignoreScripts: true }); 218 | 219 | expect(result).toBeUndefined(); 220 | expect(spawnProcess).toHaveBeenCalledTimes(1); 221 | expect(spawnProcess).toHaveBeenCalledWith( 222 | expect.stringMatching(/^yarn/), 223 | [ 'install', '--frozen-lockfile', '--non-interactive', '--ignore-scripts' ], 224 | { 225 | cwd: 'myPath' 226 | } 227 | ); 228 | }); 229 | }); 230 | 231 | describe('prune', () => { 232 | let installStub: jest.SpyInstance; 233 | 234 | beforeAll(() => { 235 | installStub = jest.spyOn(yarnModule, 'install').mockReturnValue(); 236 | }); 237 | 238 | afterAll(() => { 239 | installStub.mockRestore(); 240 | }); 241 | 242 | it('should call install', () => { 243 | yarnModule.prune('myPath', {}); 244 | 245 | expect(installStub).toHaveBeenCalledTimes(1); 246 | expect(installStub).toHaveBeenCalledWith('myPath', {}); 247 | }); 248 | }); 249 | 250 | describe('runScripts', () => { 251 | it('should use yarn run for the given scripts', () => { 252 | spawnProcess.mockReset().mockReturnValue({ stdout: 'success', stderr: '' }); 253 | 254 | const result = yarnModule.runScripts('myPath', [ 's1', 's2' ]); 255 | 256 | expect(result).toBeUndefined(); 257 | expect(spawnProcess).toHaveBeenCalledTimes(2); 258 | expect(spawnProcess).toHaveBeenNthCalledWith(1, expect.stringMatching(/^yarn/), [ 'run', 's1' ], { 259 | cwd: 'myPath' 260 | }); 261 | expect(spawnProcess).toHaveBeenNthCalledWith(2, expect.stringMatching(/^yarn/), [ 'run', 's2' ], { 262 | cwd: 'myPath' 263 | }); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "es6", 5 | "outDir": "dist", 6 | "declaration": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "strictNullChecks": true, 10 | "lib": ["ES2017"] 11 | }, 12 | "exclude": [ 13 | "node_modules" 14 | ], 15 | "include": [ 16 | "src" 17 | ] 18 | } 19 | --------------------------------------------------------------------------------