├── .all-contributorsrc ├── .commitlintrc.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── pull.yml │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .releaserc.yml ├── .vscode └── .gitignore ├── LICENSE ├── README.md ├── e2e ├── Makefile ├── __snapshots__ │ ├── complete.test.ts.snap │ ├── individually.test.ts.snap │ └── minimal.test.ts.snap ├── complete.test.ts ├── individually.test.ts ├── minimal.test.ts └── modules.d.ts ├── examples ├── complete │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── serverless.yml │ └── src │ │ └── index.ts ├── config │ ├── .gitignore │ ├── config.js │ ├── hello1.ts │ ├── hello2.ts │ ├── package.json │ └── serverless.yml ├── individually │ ├── .gitignore │ ├── hello1.ts │ ├── hello2.ts │ ├── package.json │ ├── plugins.js │ └── serverless.yml └── minimal │ ├── .gitignore │ ├── README.md │ ├── index.js │ ├── package.json │ └── serverless.yml ├── jest.config.e2e.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── bundle.ts ├── constants.ts ├── declarations.d.ts ├── helper.ts ├── index.ts ├── pack-externals.ts ├── pack.ts ├── packagers │ ├── index.ts │ ├── npm.ts │ ├── packager.ts │ ├── pnpm.ts │ └── yarn.ts ├── pre-local.ts ├── pre-offline.ts ├── tests │ ├── bundle.test.ts │ ├── helper.test.ts │ ├── index.test.ts │ ├── pack.test.ts │ ├── packagers │ │ ├── index.test.ts │ │ ├── npm.test.ts │ │ └── yarn.test.ts │ ├── pre-local.test.ts │ ├── type-predicate.test.ts │ └── util.test.ts ├── type-predicate.ts ├── types.ts ├── utils.ts └── utils │ └── effect-fs.ts ├── tsconfig.build.json └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "serverless-esbuild", 3 | "projectOwner": "floydspace", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 70, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "floydspace", 15 | "name": "Victor Korzunin", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/5180700?v=4", 17 | "profile": "https://github.com/floydspace", 18 | "contributions": [ 19 | "question", 20 | "code", 21 | "doc", 22 | "example", 23 | "ideas", 24 | "infra", 25 | "maintenance", 26 | "plugin", 27 | "projectManagement", 28 | "review", 29 | "test", 30 | "tool" 31 | ] 32 | }, 33 | { 34 | "login": "olup", 35 | "name": "Loup Topalian", 36 | "avatar_url": "https://avatars.githubusercontent.com/u/13785588?v=4", 37 | "profile": "https://github.com/olup", 38 | "contributions": [ 39 | "question", 40 | "code", 41 | "doc", 42 | "infra", 43 | "maintenance", 44 | "plugin" 45 | ] 46 | }, 47 | { 48 | "login": "samchungy", 49 | "name": "Sam Chung", 50 | "avatar_url": "https://avatars.githubusercontent.com/u/18017094?v=4", 51 | "profile": "https://github.com/samchungy", 52 | "contributions": [ 53 | "question", 54 | "code", 55 | "doc", 56 | "example", 57 | "infra", 58 | "maintenance", 59 | "plugin", 60 | "review", 61 | "tool" 62 | ] 63 | }, 64 | { 65 | "login": "vamche", 66 | "name": "Vamsi Dharmavarapu", 67 | "avatar_url": "https://avatars.githubusercontent.com/u/9653338?v=4", 68 | "profile": "https://github.com/vamche", 69 | "contributions": [ 70 | "code", 71 | "doc", 72 | "example", 73 | "infra", 74 | "maintenance" 75 | ] 76 | }, 77 | { 78 | "login": "webdeveric", 79 | "name": "Eric", 80 | "avatar_url": "https://avatars.githubusercontent.com/u/1823514?v=4", 81 | "profile": "https://github.com/webdeveric", 82 | "contributions": [ 83 | "code", 84 | "ideas", 85 | "maintenance", 86 | "infra", 87 | "review" 88 | ] 89 | }, 90 | { 91 | "login": "codingnuclei", 92 | "name": "Chris", 93 | "avatar_url": "https://avatars.githubusercontent.com/u/37954566?v=4", 94 | "profile": "https://github.com/codingnuclei", 95 | "contributions": [ 96 | "code", 97 | "ideas" 98 | ] 99 | }, 100 | { 101 | "login": "tinchoz49", 102 | "name": "Martín Acosta", 103 | "avatar_url": "https://avatars.githubusercontent.com/u/819446?v=4", 104 | "profile": "https://geutstudio.com/", 105 | "contributions": [ 106 | "code" 107 | ] 108 | }, 109 | { 110 | "login": "tonyt-adept", 111 | "name": "Tony Tyrrell", 112 | "avatar_url": "https://avatars.githubusercontent.com/u/82844324?v=4", 113 | "profile": "https://github.com/tonyt-adept", 114 | "contributions": [ 115 | "code" 116 | ] 117 | }, 118 | { 119 | "login": "mattjennings", 120 | "name": "Matt Jennings", 121 | "avatar_url": "https://avatars.githubusercontent.com/u/8703090?v=4", 122 | "profile": "https://mattjennings.io/", 123 | "contributions": [ 124 | "code" 125 | ] 126 | }, 127 | { 128 | "login": "mishabruml", 129 | "name": "Misha Bruml", 130 | "avatar_url": "https://avatars.githubusercontent.com/u/25983780?v=4", 131 | "profile": "https://github.com/mishabruml", 132 | "contributions": [ 133 | "code" 134 | ] 135 | }, 136 | { 137 | "login": "fargito", 138 | "name": "François Farge", 139 | "avatar_url": "https://avatars.githubusercontent.com/u/29537204?v=4", 140 | "profile": "https://www.swarmion.dev/", 141 | "contributions": [ 142 | "code" 143 | ] 144 | }, 145 | { 146 | "login": "ffxsam", 147 | "name": "Sam Hulick", 148 | "avatar_url": "https://avatars.githubusercontent.com/u/12532733?v=4", 149 | "profile": "https://reelcrafter.com/", 150 | "contributions": [ 151 | "doc" 152 | ] 153 | }, 154 | { 155 | "login": "troyready", 156 | "name": "Troy Ready", 157 | "avatar_url": "https://avatars.githubusercontent.com/u/1806418?v=4", 158 | "profile": "https://github.com/troyready", 159 | "contributions": [ 160 | "code" 161 | ] 162 | }, 163 | { 164 | "login": "adikari", 165 | "name": "subash adhikari", 166 | "avatar_url": "https://avatars.githubusercontent.com/u/1757714?v=4", 167 | "profile": "http://www.subash.com.au/", 168 | "contributions": [ 169 | "code" 170 | ] 171 | }, 172 | { 173 | "login": "danionescu", 174 | "name": "Dan Ionescu", 175 | "avatar_url": "https://avatars.githubusercontent.com/u/3269359?v=4", 176 | "profile": "https://github.com/danionescu", 177 | "contributions": [ 178 | "code" 179 | ] 180 | }, 181 | { 182 | "login": "gurushida", 183 | "name": "gurushida", 184 | "avatar_url": "https://avatars.githubusercontent.com/u/49831684?v=4", 185 | "profile": "https://github.com/gurushida", 186 | "contributions": [ 187 | "code" 188 | ] 189 | }, 190 | { 191 | "login": "nickygb", 192 | "name": "nickygb", 193 | "avatar_url": "https://avatars.githubusercontent.com/u/23530107?v=4", 194 | "profile": "https://github.com/nickygb", 195 | "contributions": [ 196 | "code" 197 | ] 198 | }, 199 | { 200 | "login": "capaj", 201 | "name": "Jiri Spac", 202 | "avatar_url": "https://avatars.githubusercontent.com/u/1305378?v=4", 203 | "profile": "https://twitter.com/capajj", 204 | "contributions": [ 205 | "code" 206 | ] 207 | }, 208 | { 209 | "login": "gavynriebau", 210 | "name": "gavynriebau", 211 | "avatar_url": "https://avatars.githubusercontent.com/u/11895736?v=4", 212 | "profile": "https://github.com/gavynriebau", 213 | "contributions": [ 214 | "doc" 215 | ] 216 | }, 217 | { 218 | "login": "adriencaccia", 219 | "name": "Adrien Cacciaguerra", 220 | "avatar_url": "https://avatars.githubusercontent.com/u/19605940?v=4", 221 | "profile": "https://github.com/adriencaccia", 222 | "contributions": [ 223 | "doc" 224 | ] 225 | }, 226 | { 227 | "login": "lulzneko", 228 | "name": "lulzneko", 229 | "avatar_url": "https://avatars.githubusercontent.com/u/31102213?v=4", 230 | "profile": "https://riotz.works/", 231 | "contributions": [ 232 | "code" 233 | ] 234 | }, 235 | { 236 | "login": "uneco", 237 | "name": "AOKI Yuuto", 238 | "avatar_url": "https://avatars.githubusercontent.com/u/603523?v=4", 239 | "profile": "https://u-ne.co/", 240 | "contributions": [ 241 | "code" 242 | ] 243 | }, 244 | { 245 | "login": "ThomasAribart", 246 | "name": "Thomas Aribart", 247 | "avatar_url": "https://avatars.githubusercontent.com/u/38014240?v=4", 248 | "profile": "https://github.com/ThomasAribart", 249 | "contributions": [ 250 | "ideas" 251 | ] 252 | }, 253 | { 254 | "login": "koryhutchison", 255 | "name": "Kory Hutchison", 256 | "avatar_url": "https://avatars.githubusercontent.com/u/22381273?v=4", 257 | "profile": "https://github.com/koryhutchison", 258 | "contributions": [ 259 | "code", 260 | "ideas" 261 | ] 262 | }, 263 | { 264 | "login": "chrishutchinson", 265 | "name": "Chris Hutchinson", 266 | "avatar_url": "https://avatars.githubusercontent.com/u/1573022?v=4", 267 | "profile": "https://www.chrishutchinson.me", 268 | "contributions": [ 269 | "code" 270 | ] 271 | }, 272 | { 273 | "login": "fredrik", 274 | "name": "Fredrik Möllerstrand", 275 | "avatar_url": "https://avatars.githubusercontent.com/u/12793?v=4", 276 | "profile": "http://fredrikmollerstrand.se", 277 | "contributions": [ 278 | "code" 279 | ] 280 | }, 281 | { 282 | "login": "sanderkooger", 283 | "name": "Sander Kooger", 284 | "avatar_url": "https://avatars.githubusercontent.com/u/19397354?v=4", 285 | "profile": "https://thisisfashion.tv", 286 | "contributions": [ 287 | "code" 288 | ] 289 | }, 290 | { 291 | "login": "Gleeble", 292 | "name": "Adam Swift", 293 | "avatar_url": "https://avatars.githubusercontent.com/u/1588262?v=4", 294 | "profile": "http://caffeinatedcoding.wordpress.com", 295 | "contributions": [ 296 | "code" 297 | ] 298 | }, 299 | { 300 | "login": "fm-sz", 301 | "name": "Florian Mayer", 302 | "avatar_url": "https://avatars.githubusercontent.com/u/119663527?v=4", 303 | "profile": "https://github.com/fm-sz", 304 | "contributions": [ 305 | "code" 306 | ] 307 | }, 308 | { 309 | "login": "ZachLeviPixel", 310 | "name": "Zach Levi", 311 | "avatar_url": "https://avatars.githubusercontent.com/u/131263652?v=4", 312 | "profile": "https://github.com/ZachLeviPixel", 313 | "contributions": [ 314 | "code" 315 | ] 316 | } 317 | ], 318 | "contributorsPerLine": 7, 319 | "linkToUsage": false, 320 | "commitType": "docs" 321 | } 322 | -------------------------------------------------------------------------------- /.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 | 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: 3 | - '@floydspace/eslint-config/lib' 4 | - '@floydspace/eslint-config/jest' 5 | rules: 6 | '@typescript-eslint/naming-convention': off 7 | jest/no-conditional-expect: off 8 | overrides: 9 | - files: 10 | - 'examples/**/*.[tj]s' 11 | rules: 12 | import/no-unresolved: off 13 | no-promise-executor-return: off 14 | no-console: off 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: floydspace 2 | patreon: floydspace 3 | issuehunt: floydspace 4 | ko_fi: floydspace 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior. If you have an example repository that would be even better: 14 | 15 | 1. Set X to `true` 16 | 2. Run `sls package` 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots or Logs** 22 | If applicable, add screenshots or logs to help explain your problem. 23 | 24 | **Versions (please complete the following information):** 25 | 26 | - OS: [e.g. Linux, Mac, Windows] 27 | - Serverless Framework Version: [e.g. 3.0.0] 28 | - Plugin Version: [e.g. 1.25.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: pull 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18, 20, 22.11.0] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | cache: npm 21 | - uses: pnpm/action-setup@v2 22 | with: 23 | version: 7 24 | - run: npm ci 25 | - run: npm test 26 | - run: npm run test:e2e 27 | - name: Upload e2e test artifacts 28 | uses: actions/upload-artifact@v4 29 | if: failure() 30 | with: 31 | name: e2e-test-artifact 32 | path: | 33 | .test-artifacts/**/.serverless/*.json 34 | .test-artifacts/**/.serverless/*.zip 35 | retention-days: 7 36 | - run: npm run build --if-present 37 | -------------------------------------------------------------------------------- /.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: [18, 20, 22.11.0] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | cache: npm 21 | - run: npm ci 22 | - run: npm test 23 | - run: npm run build --if-present 24 | publish: 25 | needs: test 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | - uses: actions/setup-node@v3 30 | with: 31 | node-version: 18 32 | - run: npm ci 33 | - run: npx semantic-release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .idea 4 | .serverless 5 | dist 6 | *.log 7 | yarn.lock 8 | .test-artifacts/ 9 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | preset: conventionalcommits 2 | -------------------------------------------------------------------------------- /.vscode/.gitignore: -------------------------------------------------------------------------------- 1 | settings.json 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /e2e/Makefile: -------------------------------------------------------------------------------- 1 | test-e2e: test-e2e-minimal test-e2e-individually test-e2e-complete test-e2e-config 2 | 3 | build: 4 | npm run build 5 | 6 | test-e2e-minimal: build 7 | rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/minimal && rsync -r ./examples/minimal/ ./.test-artifacts/minimal/ 8 | cd ./.test-artifacts/minimal && npm install && npx sls package 9 | cd ./.test-artifacts/minimal/.serverless && unzip minimal-example.zip 10 | npx jest -c jest.config.e2e.js --ci ./e2e/minimal.test.ts 11 | rm -fr ./.test-artifacts 12 | 13 | test-e2e-individually: build 14 | rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/individually && rsync -r ./examples/individually/ ./.test-artifacts/individually/ 15 | cd ./.test-artifacts/individually && yarn install && npx sls package 16 | cd ./.test-artifacts/individually/.serverless && unzip hello1.zip && unzip hello2.zip 17 | npx jest -c jest.config.e2e.js --ci ./e2e/individually.test.ts 18 | rm -fr ./.test-artifacts 19 | 20 | test-e2e-complete: build 21 | rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/complete && rsync -r ./examples/complete/ ./.test-artifacts/complete/ 22 | cd ./.test-artifacts/complete && npm install && npx sls package 23 | cd ./.test-artifacts/complete/.serverless && unzip complete-example.zip 24 | npx jest -c jest.config.e2e.js --ci ./e2e/complete.test.ts 25 | rm -fr ./.test-artifacts 26 | 27 | test-e2e-config: build 28 | rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/config && rsync -r ./examples/config/ ./.test-artifacts/config/ 29 | cd ./.test-artifacts/config && pnpm install && npx sls package 30 | -------------------------------------------------------------------------------- /e2e/__snapshots__/complete.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`complete 1`] = ` 4 | ""use strict";var e=Object.create;var a=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var l=Object.getPrototypeOf,u=Object.prototype.hasOwnProperty;var c=(i,n)=>{for(var s in n)a(i,s,{get:n[s],enumerable:!0})},o=(i,n,s,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of f(n))!u.call(i,t)&&t!==s&&a(i,t,{get:()=>n[t],enumerable:!(r=I(n,t))||r.enumerable});return i};var g=(i,n,s)=>(s=i!=null?e(l(i)):{},o(n||!i||!i.__esModule?a(s,"default",{value:i,enumerable:!0}):s,i)),m=i=>o(a({},"__esModule",{value:!0}),i);var y={};c(y,{handler:()=>p});module.exports=m(y);var d=g(require("isin-validator"));async function p(i){let n=(0,d.default)(i);return{statusCode:200,body:JSON.stringify({message:n?"ISIN is invalid!":"ISIN is fine!",input:i})}}0&&(module.exports={handler}); 5 | " 6 | `; 7 | 8 | exports[`complete 2`] = `"2010-09-09"`; 9 | 10 | exports[`complete 3`] = `"The AWS CloudFormation template for this Serverless application"`; 11 | 12 | exports[`complete 4`] = ` 13 | { 14 | "ServerlessDeploymentBucketName": { 15 | "Export": { 16 | "Name": "sls-complete-example-dev-ServerlessDeploymentBucketName", 17 | }, 18 | "Value": { 19 | "Ref": "ServerlessDeploymentBucket", 20 | }, 21 | }, 22 | "ServiceEndpoint": { 23 | "Description": "URL of the service endpoint", 24 | "Export": { 25 | "Name": "sls-complete-example-dev-ServiceEndpoint", 26 | }, 27 | "Value": { 28 | "Fn::Join": [ 29 | "", 30 | [ 31 | "https://", 32 | { 33 | "Ref": "ApiGatewayRestApi", 34 | }, 35 | ".execute-api.", 36 | { 37 | "Ref": "AWS::Region", 38 | }, 39 | ".", 40 | { 41 | "Ref": "AWS::URLSuffix", 42 | }, 43 | "/dev", 44 | ], 45 | ], 46 | }, 47 | }, 48 | "ValidateIsinLambdaFunctionQualifiedArn": { 49 | "Description": "Current Lambda function version", 50 | "Export": { 51 | "Name": "sls-complete-example-dev-ValidateIsinLambdaFunctionQualifiedArn", 52 | }, 53 | "Value": { 54 | "Ref": StringContaining "ValidateIsinLambdaVersion", 55 | }, 56 | }, 57 | } 58 | `; 59 | 60 | exports[`complete 5`] = ` 61 | { 62 | "ApiGatewayMethodValidateDashisinIsinVarGet": { 63 | "DependsOn": [ 64 | "ValidateIsinLambdaPermissionApiGateway", 65 | ], 66 | "Properties": { 67 | "ApiKeyRequired": false, 68 | "AuthorizationType": "NONE", 69 | "HttpMethod": "GET", 70 | "Integration": { 71 | "IntegrationHttpMethod": "POST", 72 | "Type": "AWS_PROXY", 73 | "Uri": { 74 | "Fn::Join": [ 75 | "", 76 | [ 77 | "arn:", 78 | { 79 | "Ref": "AWS::Partition", 80 | }, 81 | ":apigateway:", 82 | { 83 | "Ref": "AWS::Region", 84 | }, 85 | ":lambda:path/2015-03-31/functions/", 86 | { 87 | "Fn::GetAtt": [ 88 | "ValidateIsinLambdaFunction", 89 | "Arn", 90 | ], 91 | }, 92 | "/invocations", 93 | ], 94 | ], 95 | }, 96 | }, 97 | "MethodResponses": [], 98 | "RequestParameters": {}, 99 | "ResourceId": { 100 | "Ref": "ApiGatewayResourceValidateDashisinIsinVar", 101 | }, 102 | "RestApiId": { 103 | "Ref": "ApiGatewayRestApi", 104 | }, 105 | }, 106 | "Type": "AWS::ApiGateway::Method", 107 | }, 108 | "ApiGatewayResourceValidateDashisin": { 109 | "Properties": { 110 | "ParentId": { 111 | "Fn::GetAtt": [ 112 | "ApiGatewayRestApi", 113 | "RootResourceId", 114 | ], 115 | }, 116 | "PathPart": "validate-isin", 117 | "RestApiId": { 118 | "Ref": "ApiGatewayRestApi", 119 | }, 120 | }, 121 | "Type": "AWS::ApiGateway::Resource", 122 | }, 123 | "ApiGatewayResourceValidateDashisinIsinVar": { 124 | "Properties": { 125 | "ParentId": { 126 | "Ref": "ApiGatewayResourceValidateDashisin", 127 | }, 128 | "PathPart": "{isin}", 129 | "RestApiId": { 130 | "Ref": "ApiGatewayRestApi", 131 | }, 132 | }, 133 | "Type": "AWS::ApiGateway::Resource", 134 | }, 135 | "ApiGatewayRestApi": { 136 | "Properties": { 137 | "EndpointConfiguration": { 138 | "Types": [ 139 | "EDGE", 140 | ], 141 | }, 142 | "Name": "dev-complete-example", 143 | "Policy": "", 144 | }, 145 | "Type": "AWS::ApiGateway::RestApi", 146 | }, 147 | "IamRoleLambdaExecution": { 148 | "Properties": { 149 | "AssumeRolePolicyDocument": { 150 | "Statement": [ 151 | { 152 | "Action": [ 153 | "sts:AssumeRole", 154 | ], 155 | "Effect": "Allow", 156 | "Principal": { 157 | "Service": [ 158 | "lambda.amazonaws.com", 159 | ], 160 | }, 161 | }, 162 | ], 163 | "Version": "2012-10-17", 164 | }, 165 | "Path": "/", 166 | "Policies": [ 167 | { 168 | "PolicyDocument": { 169 | "Statement": [ 170 | { 171 | "Action": [ 172 | "logs:CreateLogStream", 173 | "logs:CreateLogGroup", 174 | "logs:TagResource", 175 | ], 176 | "Effect": "Allow", 177 | "Resource": [ 178 | { 179 | "Fn::Sub": "arn:\${AWS::Partition}:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/lambda/complete-example-dev*:*", 180 | }, 181 | ], 182 | }, 183 | { 184 | "Action": [ 185 | "logs:PutLogEvents", 186 | ], 187 | "Effect": "Allow", 188 | "Resource": [ 189 | { 190 | "Fn::Sub": "arn:\${AWS::Partition}:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/lambda/complete-example-dev*:*:*", 191 | }, 192 | ], 193 | }, 194 | ], 195 | "Version": "2012-10-17", 196 | }, 197 | "PolicyName": { 198 | "Fn::Join": [ 199 | "-", 200 | [ 201 | "complete-example", 202 | "dev", 203 | "lambda", 204 | ], 205 | ], 206 | }, 207 | }, 208 | ], 209 | "RoleName": { 210 | "Fn::Join": [ 211 | "-", 212 | [ 213 | "complete-example", 214 | "dev", 215 | { 216 | "Ref": "AWS::Region", 217 | }, 218 | "lambdaRole", 219 | ], 220 | ], 221 | }, 222 | }, 223 | "Type": "AWS::IAM::Role", 224 | }, 225 | "ServerlessDeploymentBucket": { 226 | "Properties": { 227 | "BucketEncryption": { 228 | "ServerSideEncryptionConfiguration": [ 229 | { 230 | "ServerSideEncryptionByDefault": { 231 | "SSEAlgorithm": "AES256", 232 | }, 233 | }, 234 | ], 235 | }, 236 | }, 237 | "Type": "AWS::S3::Bucket", 238 | }, 239 | "ServerlessDeploymentBucketPolicy": { 240 | "Properties": { 241 | "Bucket": { 242 | "Ref": "ServerlessDeploymentBucket", 243 | }, 244 | "PolicyDocument": { 245 | "Statement": [ 246 | { 247 | "Action": "s3:*", 248 | "Condition": { 249 | "Bool": { 250 | "aws:SecureTransport": false, 251 | }, 252 | }, 253 | "Effect": "Deny", 254 | "Principal": "*", 255 | "Resource": [ 256 | { 257 | "Fn::Join": [ 258 | "", 259 | [ 260 | "arn:", 261 | { 262 | "Ref": "AWS::Partition", 263 | }, 264 | ":s3:::", 265 | { 266 | "Ref": "ServerlessDeploymentBucket", 267 | }, 268 | "/*", 269 | ], 270 | ], 271 | }, 272 | { 273 | "Fn::Join": [ 274 | "", 275 | [ 276 | "arn:", 277 | { 278 | "Ref": "AWS::Partition", 279 | }, 280 | ":s3:::", 281 | { 282 | "Ref": "ServerlessDeploymentBucket", 283 | }, 284 | ], 285 | ], 286 | }, 287 | ], 288 | }, 289 | ], 290 | }, 291 | }, 292 | "Type": "AWS::S3::BucketPolicy", 293 | }, 294 | "ValidateIsinLambdaFunction": { 295 | "DependsOn": [ 296 | "ValidateIsinLogGroup", 297 | ], 298 | "Properties": { 299 | "Code": { 300 | "S3Bucket": { 301 | "Ref": "ServerlessDeploymentBucket", 302 | }, 303 | "S3Key": StringContaining "complete-example.zip", 304 | }, 305 | "FunctionName": "complete-example-dev-validateIsin", 306 | "Handler": "src/index.handler", 307 | "MemorySize": 1024, 308 | "Role": { 309 | "Fn::GetAtt": [ 310 | "IamRoleLambdaExecution", 311 | "Arn", 312 | ], 313 | }, 314 | "Runtime": "nodejs18.x", 315 | "Timeout": 6, 316 | }, 317 | "Type": "AWS::Lambda::Function", 318 | }, 319 | "ValidateIsinLambdaPermissionApiGateway": { 320 | "Properties": { 321 | "Action": "lambda:InvokeFunction", 322 | "FunctionName": { 323 | "Fn::GetAtt": [ 324 | "ValidateIsinLambdaFunction", 325 | "Arn", 326 | ], 327 | }, 328 | "Principal": "apigateway.amazonaws.com", 329 | "SourceArn": { 330 | "Fn::Join": [ 331 | "", 332 | [ 333 | "arn:", 334 | { 335 | "Ref": "AWS::Partition", 336 | }, 337 | ":execute-api:", 338 | { 339 | "Ref": "AWS::Region", 340 | }, 341 | ":", 342 | { 343 | "Ref": "AWS::AccountId", 344 | }, 345 | ":", 346 | { 347 | "Ref": "ApiGatewayRestApi", 348 | }, 349 | "/*/*", 350 | ], 351 | ], 352 | }, 353 | }, 354 | "Type": "AWS::Lambda::Permission", 355 | }, 356 | "ValidateIsinLogGroup": { 357 | "Properties": { 358 | "LogGroupName": "/aws/lambda/complete-example-dev-validateIsin", 359 | }, 360 | "Type": "AWS::Logs::LogGroup", 361 | }, 362 | } 363 | `; 364 | 365 | exports[`complete 6`] = ` 366 | { 367 | "DependsOn": [ 368 | "ApiGatewayMethodValidateDashisinIsinVarGet", 369 | ], 370 | "Properties": { 371 | "RestApiId": { 372 | "Ref": "ApiGatewayRestApi", 373 | }, 374 | "StageName": "dev", 375 | }, 376 | "Type": "AWS::ApiGateway::Deployment", 377 | } 378 | `; 379 | 380 | exports[`complete 7`] = ` 381 | { 382 | "DeletionPolicy": "Retain", 383 | "Properties": { 384 | "CodeSha256": Any, 385 | "FunctionName": { 386 | "Ref": "ValidateIsinLambdaFunction", 387 | }, 388 | }, 389 | "Type": "AWS::Lambda::Version", 390 | } 391 | `; 392 | -------------------------------------------------------------------------------- /e2e/__snapshots__/individually.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`individually 1`] = ` 4 | ""use strict";var l=Object.create;var n=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,d=Object.prototype.hasOwnProperty;var u=(e,s)=>n(e,"name",{value:s,configurable:!0});var p=(e,s)=>{for(var o in s)n(e,o,{get:s[o],enumerable:!0})},c=(e,s,o,r)=>{if(s&&typeof s=="object"||typeof s=="function")for(let t of f(s))!d.call(e,t)&&t!==o&&n(e,t,{get:()=>s[t],enumerable:!(r=a(s,t))||r.enumerable});return e};var y=(e,s,o)=>(o=e!=null?l(m(e)):{},c(s||!e||!e.__esModule?n(o,"default",{value:e,enumerable:!0}):o,e)),g=e=>c(n({},"__esModule",{value:!0}),e);var S={};p(S,{handler:()=>x});module.exports=g(S);var i=y(require("lodash"));async function x(e,s,o){console.log(i.VERSION),await new Promise(t=>setTimeout(t,500));let r={statusCode:200,body:JSON.stringify({message:"Go Serverless v1.0! Your function executed successfully!",input:e})};o(null,r)}u(x,"handler");0&&(module.exports={handler}); 5 | " 6 | `; 7 | 8 | exports[`individually 2`] = ` 9 | ""use strict";var u=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var r=Object.getOwnPropertyNames;var a=Object.prototype.hasOwnProperty;var c=(s,e)=>u(s,"name",{value:e,configurable:!0});var l=(s,e)=>{for(var n in e)u(s,n,{get:e[n],enumerable:!0})},d=(s,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of r(e))!a.call(s,t)&&t!==n&&u(s,t,{get:()=>e[t],enumerable:!(o=i(e,t))||o.enumerable});return s};var f=s=>d(u({},"__esModule",{value:!0}),s);var m={};l(m,{handler:()=>y});module.exports=f(m);async function y(s,e,n){await new Promise(t=>setTimeout(t,500));let o={statusCode:200,body:JSON.stringify({message:"Go Serverless v1.0! Your function executed successfully!",input:s})};n(null,o)}c(y,"handler");0&&(module.exports={handler}); 10 | " 11 | `; 12 | 13 | exports[`individually 3`] = `"2010-09-09"`; 14 | 15 | exports[`individually 4`] = `"The AWS CloudFormation template for this Serverless application"`; 16 | 17 | exports[`individually 5`] = ` 18 | { 19 | "Hello1LambdaFunctionQualifiedArn": { 20 | "Description": "Current Lambda function version", 21 | "Export": { 22 | "Name": "sls-serverless-example-dev-Hello1LambdaFunctionQualifiedArn", 23 | }, 24 | "Value": { 25 | "Ref": Any, 26 | }, 27 | }, 28 | "Hello2LambdaFunctionQualifiedArn": { 29 | "Description": "Current Lambda function version", 30 | "Export": { 31 | "Name": "sls-serverless-example-dev-Hello2LambdaFunctionQualifiedArn", 32 | }, 33 | "Value": { 34 | "Ref": Any, 35 | }, 36 | }, 37 | "ServerlessDeploymentBucketName": { 38 | "Export": { 39 | "Name": "sls-serverless-example-dev-ServerlessDeploymentBucketName", 40 | }, 41 | "Value": { 42 | "Ref": "ServerlessDeploymentBucket", 43 | }, 44 | }, 45 | "ServiceEndpoint": { 46 | "Description": "URL of the service endpoint", 47 | "Export": { 48 | "Name": "sls-serverless-example-dev-ServiceEndpoint", 49 | }, 50 | "Value": { 51 | "Fn::Join": [ 52 | "", 53 | [ 54 | "https://", 55 | { 56 | "Ref": "ApiGatewayRestApi", 57 | }, 58 | ".execute-api.", 59 | { 60 | "Ref": "AWS::Region", 61 | }, 62 | ".", 63 | { 64 | "Ref": "AWS::URLSuffix", 65 | }, 66 | "/dev", 67 | ], 68 | ], 69 | }, 70 | }, 71 | } 72 | `; 73 | 74 | exports[`individually 6`] = ` 75 | { 76 | "ApiGatewayMethodHello1Get": { 77 | "DependsOn": [ 78 | "Hello1LambdaPermissionApiGateway", 79 | ], 80 | "Properties": { 81 | "ApiKeyRequired": false, 82 | "AuthorizationType": "NONE", 83 | "HttpMethod": "GET", 84 | "Integration": { 85 | "IntegrationHttpMethod": "POST", 86 | "Type": "AWS_PROXY", 87 | "Uri": { 88 | "Fn::Join": [ 89 | "", 90 | [ 91 | "arn:", 92 | { 93 | "Ref": "AWS::Partition", 94 | }, 95 | ":apigateway:", 96 | { 97 | "Ref": "AWS::Region", 98 | }, 99 | ":lambda:path/2015-03-31/functions/", 100 | { 101 | "Fn::GetAtt": [ 102 | "Hello1LambdaFunction", 103 | "Arn", 104 | ], 105 | }, 106 | "/invocations", 107 | ], 108 | ], 109 | }, 110 | }, 111 | "MethodResponses": [], 112 | "RequestParameters": {}, 113 | "ResourceId": { 114 | "Ref": "ApiGatewayResourceHello1", 115 | }, 116 | "RestApiId": { 117 | "Ref": "ApiGatewayRestApi", 118 | }, 119 | }, 120 | "Type": "AWS::ApiGateway::Method", 121 | }, 122 | "ApiGatewayMethodHello2Get": { 123 | "DependsOn": [ 124 | "Hello2LambdaPermissionApiGateway", 125 | ], 126 | "Properties": { 127 | "ApiKeyRequired": false, 128 | "AuthorizationType": "NONE", 129 | "HttpMethod": "GET", 130 | "Integration": { 131 | "IntegrationHttpMethod": "POST", 132 | "Type": "AWS_PROXY", 133 | "Uri": { 134 | "Fn::Join": [ 135 | "", 136 | [ 137 | "arn:", 138 | { 139 | "Ref": "AWS::Partition", 140 | }, 141 | ":apigateway:", 142 | { 143 | "Ref": "AWS::Region", 144 | }, 145 | ":lambda:path/2015-03-31/functions/", 146 | { 147 | "Fn::GetAtt": [ 148 | "Hello2LambdaFunction", 149 | "Arn", 150 | ], 151 | }, 152 | "/invocations", 153 | ], 154 | ], 155 | }, 156 | }, 157 | "MethodResponses": [], 158 | "RequestParameters": {}, 159 | "ResourceId": { 160 | "Ref": "ApiGatewayResourceHello2", 161 | }, 162 | "RestApiId": { 163 | "Ref": "ApiGatewayRestApi", 164 | }, 165 | }, 166 | "Type": "AWS::ApiGateway::Method", 167 | }, 168 | "ApiGatewayResourceHello1": { 169 | "Properties": { 170 | "ParentId": { 171 | "Fn::GetAtt": [ 172 | "ApiGatewayRestApi", 173 | "RootResourceId", 174 | ], 175 | }, 176 | "PathPart": "hello1", 177 | "RestApiId": { 178 | "Ref": "ApiGatewayRestApi", 179 | }, 180 | }, 181 | "Type": "AWS::ApiGateway::Resource", 182 | }, 183 | "ApiGatewayResourceHello2": { 184 | "Properties": { 185 | "ParentId": { 186 | "Fn::GetAtt": [ 187 | "ApiGatewayRestApi", 188 | "RootResourceId", 189 | ], 190 | }, 191 | "PathPart": "hello2", 192 | "RestApiId": { 193 | "Ref": "ApiGatewayRestApi", 194 | }, 195 | }, 196 | "Type": "AWS::ApiGateway::Resource", 197 | }, 198 | "ApiGatewayRestApi": { 199 | "Properties": { 200 | "EndpointConfiguration": { 201 | "Types": [ 202 | "EDGE", 203 | ], 204 | }, 205 | "Name": "dev-serverless-example", 206 | "Policy": "", 207 | }, 208 | "Type": "AWS::ApiGateway::RestApi", 209 | }, 210 | "Hello1LambdaFunction": { 211 | "DependsOn": [ 212 | "Hello1LogGroup", 213 | ], 214 | "Properties": { 215 | "Code": { 216 | "S3Bucket": { 217 | "Ref": "ServerlessDeploymentBucket", 218 | }, 219 | "S3Key": StringContaining "hello1.zip", 220 | }, 221 | "FunctionName": "serverless-example-dev-hello1", 222 | "Handler": "hello1.handler", 223 | "MemorySize": 1024, 224 | "Role": { 225 | "Fn::GetAtt": [ 226 | "IamRoleLambdaExecution", 227 | "Arn", 228 | ], 229 | }, 230 | "Runtime": "nodejs18.x", 231 | "Timeout": 6, 232 | }, 233 | "Type": "AWS::Lambda::Function", 234 | }, 235 | "Hello1LambdaPermissionApiGateway": { 236 | "Properties": { 237 | "Action": "lambda:InvokeFunction", 238 | "FunctionName": { 239 | "Fn::GetAtt": [ 240 | "Hello1LambdaFunction", 241 | "Arn", 242 | ], 243 | }, 244 | "Principal": "apigateway.amazonaws.com", 245 | "SourceArn": { 246 | "Fn::Join": [ 247 | "", 248 | [ 249 | "arn:", 250 | { 251 | "Ref": "AWS::Partition", 252 | }, 253 | ":execute-api:", 254 | { 255 | "Ref": "AWS::Region", 256 | }, 257 | ":", 258 | { 259 | "Ref": "AWS::AccountId", 260 | }, 261 | ":", 262 | { 263 | "Ref": "ApiGatewayRestApi", 264 | }, 265 | "/*/*", 266 | ], 267 | ], 268 | }, 269 | }, 270 | "Type": "AWS::Lambda::Permission", 271 | }, 272 | "Hello1LogGroup": { 273 | "Properties": { 274 | "LogGroupName": "/aws/lambda/serverless-example-dev-hello1", 275 | }, 276 | "Type": "AWS::Logs::LogGroup", 277 | }, 278 | "Hello2LambdaFunction": { 279 | "DependsOn": [ 280 | "Hello2LogGroup", 281 | ], 282 | "Properties": { 283 | "Code": { 284 | "S3Bucket": { 285 | "Ref": "ServerlessDeploymentBucket", 286 | }, 287 | "S3Key": StringContaining "hello2.zip", 288 | }, 289 | "FunctionName": "serverless-example-dev-hello2", 290 | "Handler": "hello2.handler", 291 | "MemorySize": 1024, 292 | "Role": { 293 | "Fn::GetAtt": [ 294 | "IamRoleLambdaExecution", 295 | "Arn", 296 | ], 297 | }, 298 | "Runtime": "nodejs18.x", 299 | "Timeout": 6, 300 | }, 301 | "Type": "AWS::Lambda::Function", 302 | }, 303 | "Hello2LambdaPermissionApiGateway": { 304 | "Properties": { 305 | "Action": "lambda:InvokeFunction", 306 | "FunctionName": { 307 | "Fn::GetAtt": [ 308 | "Hello2LambdaFunction", 309 | "Arn", 310 | ], 311 | }, 312 | "Principal": "apigateway.amazonaws.com", 313 | "SourceArn": { 314 | "Fn::Join": [ 315 | "", 316 | [ 317 | "arn:", 318 | { 319 | "Ref": "AWS::Partition", 320 | }, 321 | ":execute-api:", 322 | { 323 | "Ref": "AWS::Region", 324 | }, 325 | ":", 326 | { 327 | "Ref": "AWS::AccountId", 328 | }, 329 | ":", 330 | { 331 | "Ref": "ApiGatewayRestApi", 332 | }, 333 | "/*/*", 334 | ], 335 | ], 336 | }, 337 | }, 338 | "Type": "AWS::Lambda::Permission", 339 | }, 340 | "Hello2LogGroup": { 341 | "Properties": { 342 | "LogGroupName": "/aws/lambda/serverless-example-dev-hello2", 343 | }, 344 | "Type": "AWS::Logs::LogGroup", 345 | }, 346 | "IamRoleLambdaExecution": { 347 | "Properties": { 348 | "AssumeRolePolicyDocument": { 349 | "Statement": [ 350 | { 351 | "Action": [ 352 | "sts:AssumeRole", 353 | ], 354 | "Effect": "Allow", 355 | "Principal": { 356 | "Service": [ 357 | "lambda.amazonaws.com", 358 | ], 359 | }, 360 | }, 361 | ], 362 | "Version": "2012-10-17", 363 | }, 364 | "Path": "/", 365 | "Policies": [ 366 | { 367 | "PolicyDocument": { 368 | "Statement": [ 369 | { 370 | "Action": [ 371 | "logs:CreateLogStream", 372 | "logs:CreateLogGroup", 373 | "logs:TagResource", 374 | ], 375 | "Effect": "Allow", 376 | "Resource": [ 377 | { 378 | "Fn::Sub": "arn:\${AWS::Partition}:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/lambda/serverless-example-dev*:*", 379 | }, 380 | ], 381 | }, 382 | { 383 | "Action": [ 384 | "logs:PutLogEvents", 385 | ], 386 | "Effect": "Allow", 387 | "Resource": [ 388 | { 389 | "Fn::Sub": "arn:\${AWS::Partition}:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/lambda/serverless-example-dev*:*:*", 390 | }, 391 | ], 392 | }, 393 | ], 394 | "Version": "2012-10-17", 395 | }, 396 | "PolicyName": { 397 | "Fn::Join": [ 398 | "-", 399 | [ 400 | "serverless-example", 401 | "dev", 402 | "lambda", 403 | ], 404 | ], 405 | }, 406 | }, 407 | ], 408 | "RoleName": { 409 | "Fn::Join": [ 410 | "-", 411 | [ 412 | "serverless-example", 413 | "dev", 414 | { 415 | "Ref": "AWS::Region", 416 | }, 417 | "lambdaRole", 418 | ], 419 | ], 420 | }, 421 | }, 422 | "Type": "AWS::IAM::Role", 423 | }, 424 | "ServerlessDeploymentBucket": { 425 | "Properties": { 426 | "BucketEncryption": { 427 | "ServerSideEncryptionConfiguration": [ 428 | { 429 | "ServerSideEncryptionByDefault": { 430 | "SSEAlgorithm": "AES256", 431 | }, 432 | }, 433 | ], 434 | }, 435 | }, 436 | "Type": "AWS::S3::Bucket", 437 | }, 438 | "ServerlessDeploymentBucketPolicy": { 439 | "Properties": { 440 | "Bucket": { 441 | "Ref": "ServerlessDeploymentBucket", 442 | }, 443 | "PolicyDocument": { 444 | "Statement": [ 445 | { 446 | "Action": "s3:*", 447 | "Condition": { 448 | "Bool": { 449 | "aws:SecureTransport": false, 450 | }, 451 | }, 452 | "Effect": "Deny", 453 | "Principal": "*", 454 | "Resource": [ 455 | { 456 | "Fn::Join": [ 457 | "", 458 | [ 459 | "arn:", 460 | { 461 | "Ref": "AWS::Partition", 462 | }, 463 | ":s3:::", 464 | { 465 | "Ref": "ServerlessDeploymentBucket", 466 | }, 467 | "/*", 468 | ], 469 | ], 470 | }, 471 | { 472 | "Fn::Join": [ 473 | "", 474 | [ 475 | "arn:", 476 | { 477 | "Ref": "AWS::Partition", 478 | }, 479 | ":s3:::", 480 | { 481 | "Ref": "ServerlessDeploymentBucket", 482 | }, 483 | ], 484 | ], 485 | }, 486 | ], 487 | }, 488 | ], 489 | }, 490 | }, 491 | "Type": "AWS::S3::BucketPolicy", 492 | }, 493 | } 494 | `; 495 | 496 | exports[`individually 7`] = ` 497 | { 498 | "DependsOn": [ 499 | "ApiGatewayMethodHello1Get", 500 | "ApiGatewayMethodHello2Get", 501 | ], 502 | "Properties": { 503 | "RestApiId": { 504 | "Ref": "ApiGatewayRestApi", 505 | }, 506 | "StageName": "dev", 507 | }, 508 | "Type": "AWS::ApiGateway::Deployment", 509 | } 510 | `; 511 | 512 | exports[`individually 8`] = ` 513 | { 514 | "DeletionPolicy": "Retain", 515 | "Properties": { 516 | "CodeSha256": Any, 517 | "FunctionName": { 518 | "Ref": "Hello1LambdaFunction", 519 | }, 520 | }, 521 | "Type": "AWS::Lambda::Version", 522 | } 523 | `; 524 | 525 | exports[`individually 9`] = ` 526 | { 527 | "DeletionPolicy": "Retain", 528 | "Properties": { 529 | "CodeSha256": Any, 530 | "FunctionName": { 531 | "Ref": "Hello2LambdaFunction", 532 | }, 533 | }, 534 | "Type": "AWS::Lambda::Version", 535 | } 536 | `; 537 | -------------------------------------------------------------------------------- /e2e/complete.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | test('complete', () => { 5 | const testArtifactPath = path.resolve(__dirname, '../.test-artifacts/complete/.serverless'); 6 | 7 | const cloudformation = require(path.join(testArtifactPath, 'cloudformation-template-update-stack.json')); 8 | 9 | const indexContents = fs.readFileSync(path.join(testArtifactPath, 'src/index.js')).toString(); 10 | 11 | expect(indexContents).toMatchSnapshot(); 12 | 13 | const nodeModules = fs.readdirSync(path.join(testArtifactPath, 'node_modules')).toString(); 14 | 15 | expect(nodeModules).toEqual(expect.stringContaining('isin-validator')); 16 | 17 | expect(cloudformation.AWSTemplateFormatVersion).toMatchSnapshot(); 18 | 19 | expect(cloudformation.Description).toMatchSnapshot(); 20 | 21 | expect(cloudformation.Outputs).toMatchSnapshot({ 22 | ValidateIsinLambdaFunctionQualifiedArn: { 23 | Value: { 24 | Ref: expect.stringContaining('ValidateIsinLambdaVersion'), 25 | }, 26 | }, 27 | }); 28 | 29 | const apiGatewayDeploymentPropertyKey = Object.keys(cloudformation.Resources).find((s) => 30 | s.startsWith('ApiGatewayDeployment') 31 | ) as keyof typeof cloudformation.Resources; 32 | 33 | const validateIsinLambdaVersionPropertyKey = cloudformation.Outputs.ValidateIsinLambdaFunctionQualifiedArn.Value 34 | .Ref as keyof typeof cloudformation.Resources; 35 | 36 | const { 37 | [apiGatewayDeploymentPropertyKey]: apiGatewayDeployment, 38 | [validateIsinLambdaVersionPropertyKey]: validateIsinLambdaVersion, 39 | ...deterministicResources 40 | } = cloudformation.Resources; 41 | 42 | expect(deterministicResources).toMatchSnapshot({ 43 | ValidateIsinLambdaFunction: { 44 | Properties: { 45 | Code: { S3Key: expect.stringContaining('complete-example.zip') }, 46 | }, 47 | }, 48 | }); 49 | 50 | expect(apiGatewayDeployment).toMatchSnapshot(); 51 | 52 | expect(validateIsinLambdaVersion).toMatchSnapshot({ 53 | Properties: { 54 | CodeSha256: expect.any(String), 55 | }, 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /e2e/individually.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | test('individually', () => { 5 | const testArtifactPath = path.resolve(__dirname, '../.test-artifacts/individually/.serverless'); 6 | 7 | const cloudformation = require(path.join(testArtifactPath, 'cloudformation-template-update-stack.json')); 8 | 9 | const hello1indexContents = fs.readFileSync(path.join(testArtifactPath, 'hello1.js')).toString(); 10 | const hello2indexContents = fs.readFileSync(path.join(testArtifactPath, 'hello2.js')).toString(); 11 | 12 | expect(hello1indexContents).toMatchSnapshot(); 13 | 14 | expect(hello2indexContents).toMatchSnapshot(); 15 | 16 | expect(cloudformation.AWSTemplateFormatVersion).toMatchSnapshot(); 17 | 18 | expect(cloudformation.Description).toMatchSnapshot(); 19 | 20 | expect(cloudformation.Outputs).toMatchSnapshot({ 21 | Hello1LambdaFunctionQualifiedArn: { 22 | Value: { Ref: expect.any(String) }, 23 | }, 24 | Hello2LambdaFunctionQualifiedArn: { 25 | Value: { Ref: expect.any(String) }, 26 | }, 27 | }); 28 | 29 | expect(cloudformation.Outputs.Hello1LambdaFunctionQualifiedArn.Value.Ref).toMatch(/^Hello1LambdaVersion/); 30 | 31 | expect(cloudformation.Outputs.Hello2LambdaFunctionQualifiedArn.Value.Ref).toMatch(/^Hello2LambdaVersion/); 32 | 33 | const apiGatewayDeploymentPropertyKey = Object.keys(cloudformation.Resources).find((s) => 34 | s.startsWith('ApiGatewayDeployment') 35 | ) as keyof typeof cloudformation.Resources; 36 | 37 | const hello1LambdaVersionPropertyKey = cloudformation.Outputs.Hello1LambdaFunctionQualifiedArn.Value 38 | .Ref as keyof typeof cloudformation.Resources; 39 | 40 | const hello2LambdaVersionPropertyKey = cloudformation.Outputs.Hello2LambdaFunctionQualifiedArn.Value 41 | .Ref as keyof typeof cloudformation.Resources; 42 | 43 | const { 44 | [apiGatewayDeploymentPropertyKey]: apiGatewayDeployment, 45 | [hello1LambdaVersionPropertyKey]: hello1LambdaVersion, 46 | [hello2LambdaVersionPropertyKey]: hello2LambdaVersion, 47 | ...deterministicResources 48 | } = cloudformation.Resources; 49 | 50 | expect(deterministicResources).toMatchSnapshot({ 51 | Hello1LambdaFunction: { 52 | Properties: { 53 | Code: { S3Key: expect.stringContaining('hello1.zip') }, 54 | }, 55 | }, 56 | Hello2LambdaFunction: { 57 | Properties: { 58 | Code: { S3Key: expect.stringContaining('hello2.zip') }, 59 | }, 60 | }, 61 | }); 62 | 63 | expect(apiGatewayDeployment).toMatchSnapshot(); 64 | expect(hello1LambdaVersion).toMatchSnapshot({ 65 | Properties: { CodeSha256: expect.any(String) }, 66 | }); 67 | expect(hello2LambdaVersion).toMatchSnapshot({ 68 | Properties: { CodeSha256: expect.any(String) }, 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /e2e/minimal.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | test('minimal', () => { 5 | const testArtifactPath = path.resolve(__dirname, '../.test-artifacts/minimal/.serverless'); 6 | 7 | const cloudformation = require(path.join(testArtifactPath, 'cloudformation-template-update-stack.json')); 8 | 9 | const indexContents = fs.readFileSync(path.join(testArtifactPath, 'index.js')).toString(); 10 | 11 | expect(indexContents).toMatchSnapshot(); 12 | 13 | expect(cloudformation.AWSTemplateFormatVersion).toMatchSnapshot(); 14 | 15 | expect(cloudformation.Description).toMatchSnapshot(); 16 | 17 | expect(cloudformation.Outputs).toMatchSnapshot({ 18 | ValidateIsinLambdaFunctionQualifiedArn: { 19 | Value: { Ref: expect.any(String) }, 20 | }, 21 | }); 22 | 23 | expect(cloudformation.Outputs.ValidateIsinLambdaFunctionQualifiedArn.Value.Ref).toMatch(/^ValidateIsinLambdaVersion/); 24 | 25 | const apiGatewayDeploymentPropertyKey = Object.keys(cloudformation.Resources).find((s) => 26 | s.startsWith('ApiGatewayDeployment') 27 | ) as keyof typeof cloudformation.Resources; 28 | 29 | const validateIsinLambdaVersionPropertyKey = cloudformation.Outputs.ValidateIsinLambdaFunctionQualifiedArn.Value 30 | .Ref as keyof typeof cloudformation.Resources; 31 | 32 | const { 33 | [apiGatewayDeploymentPropertyKey]: apiGatewayDeployment, 34 | [validateIsinLambdaVersionPropertyKey]: validateIsinLambdaVersion, 35 | ...deterministicResources 36 | } = cloudformation.Resources; 37 | 38 | expect(deterministicResources).toMatchSnapshot({ 39 | ValidateIsinLambdaFunction: { 40 | Properties: { 41 | Code: { S3Key: expect.stringContaining('minimal-example.zip') }, 42 | }, 43 | }, 44 | }); 45 | 46 | expect(apiGatewayDeployment).toMatchSnapshot(); 47 | expect(validateIsinLambdaVersion).toMatchSnapshot({ 48 | Properties: { CodeSha256: expect.any(String) }, 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /e2e/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*/minimal/.serverless/cloudformation-template-update-stack.json' { 2 | const json: Record; 3 | export default json; 4 | } 5 | 6 | declare module '*/individually/.serverless/cloudformation-template-update-stack.json' { 7 | const json: Record; 8 | export default json; 9 | } 10 | 11 | declare module '*/complete/.serverless/cloudformation-template-update-stack.json' { 12 | const json: Record; 13 | export default json; 14 | } 15 | -------------------------------------------------------------------------------- /examples/complete/.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .serverless 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /examples/complete/README.md: -------------------------------------------------------------------------------- 1 | # [serverless-esbuild](../../README.md) complete example 2 | 3 | This example shows how to use the `serverless-esbuild` plugin in the most common way. 4 | 5 | Any package set as `external` in the `custom.esbuild` 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/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "complete", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "sls offline" 6 | }, 7 | "dependencies": { 8 | "isin-validator": "^1.1.1" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "^18.7.21", 12 | "esbuild": "^0.24.0", 13 | "serverless": "^3.22.0", 14 | "serverless-esbuild": "file:../..", 15 | "serverless-offline": "^10.2.1", 16 | "ts-node": "^10.9.1", 17 | "typescript": "^4.8.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/complete/serverless.yml: -------------------------------------------------------------------------------- 1 | service: complete-example 2 | 3 | plugins: 4 | - serverless-esbuild 5 | - serverless-offline 6 | 7 | custom: 8 | esbuild: 9 | minify: true 10 | external: 11 | - isin-validator 12 | watch: 13 | # anymatch-compatible definition (https://github.com/es128/anymatch) 14 | pattern: ['./index.ts', 'src/**/*.ts'] # default . 15 | ignore: ['.serverless/**/*', '.build'] # default ['.build', 'dist', 'node_modules'] 16 | 17 | provider: 18 | name: aws 19 | runtime: nodejs18.x 20 | 21 | functions: 22 | validateIsin: 23 | handler: src/index.handler 24 | events: 25 | - http: 26 | path: validate-isin/{isin} 27 | method: get 28 | -------------------------------------------------------------------------------- /examples/complete/src/index.ts: -------------------------------------------------------------------------------- 1 | import validateIsin from 'isin-validator'; 2 | 3 | export async 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/config/.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .serverless 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /examples/config/config.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return { 3 | packager: 'pnpm', 4 | bundle: true, 5 | minify: true, 6 | sourcemap: false, 7 | keepNames: true, 8 | external: ['lodash'], 9 | plugins: [ 10 | { 11 | name: 'log-lodash', 12 | setup(build) { 13 | // test interception : log all lodash imports 14 | build.onResolve({ filter: /^lodash$/ }, (args) => { 15 | console.log(args); 16 | }); 17 | }, 18 | }, 19 | ], 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /examples/config/hello1.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | // modern module syntax 4 | export async function handler(event, context, callback) { 5 | // dependencies work as expected 6 | console.log(_.VERSION); 7 | 8 | // async/await also works out of the box 9 | await new Promise((resolve) => setTimeout(resolve, 500)); 10 | 11 | const response = { 12 | statusCode: 200, 13 | body: JSON.stringify({ 14 | message: 'Go Serverless v1.0! Your function executed successfully!', 15 | input: event, 16 | }), 17 | }; 18 | 19 | callback(null, response); 20 | } 21 | -------------------------------------------------------------------------------- /examples/config/hello2.ts: -------------------------------------------------------------------------------- 1 | // modern module syntax 2 | export async function handler(event, context, callback) { 3 | // async/await also works out of the box 4 | await new Promise((resolve) => setTimeout(resolve, 500)); 5 | 6 | const response = { 7 | statusCode: 200, 8 | body: JSON.stringify({ 9 | message: 'Go Serverless v1.0! Your function executed successfully!', 10 | input: event, 11 | }), 12 | }; 13 | 14 | callback(null, response); 15 | } 16 | -------------------------------------------------------------------------------- /examples/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "handler.js", 3 | "scripts": { 4 | "start": "sls offline" 5 | }, 6 | "dependencies": { 7 | "lodash": "^4.17.21" 8 | }, 9 | "devDependencies": { 10 | "@types/lodash": "4.14.185", 11 | "@types/node": "^18.7.21", 12 | "esbuild": "^0.24.0", 13 | "serverless": "^3.22.0", 14 | "serverless-esbuild": "file:../..", 15 | "serverless-offline": "^10.2.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/config/serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-example 2 | 3 | plugins: 4 | - serverless-esbuild 5 | - serverless-offline 6 | 7 | package: 8 | individually: true 9 | 10 | provider: 11 | name: aws 12 | runtime: nodejs18.x 13 | 14 | custom: 15 | esbuild: 16 | config: './config.js' 17 | 18 | functions: 19 | hello1: 20 | handler: hello1.handler 21 | events: 22 | - http: 23 | path: hello 24 | method: get 25 | 26 | hello2: 27 | handler: hello2.handler 28 | events: 29 | - http: 30 | path: hello2 31 | method: get 32 | -------------------------------------------------------------------------------- /examples/individually/.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .serverless 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /examples/individually/hello1.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | // modern module syntax 4 | export async function handler(event, context, callback) { 5 | // dependencies work as expected 6 | console.log(_.VERSION); 7 | 8 | // async/await also works out of the box 9 | await new Promise((resolve) => setTimeout(resolve, 500)); 10 | 11 | const response = { 12 | statusCode: 200, 13 | body: JSON.stringify({ 14 | message: 'Go Serverless v1.0! Your function executed successfully!', 15 | input: event, 16 | }), 17 | }; 18 | 19 | callback(null, response); 20 | } 21 | -------------------------------------------------------------------------------- /examples/individually/hello2.ts: -------------------------------------------------------------------------------- 1 | // modern module syntax 2 | export async function handler(event, context, callback) { 3 | // async/await also works out of the box 4 | await new Promise((resolve) => setTimeout(resolve, 500)); 5 | 6 | const response = { 7 | statusCode: 200, 8 | body: JSON.stringify({ 9 | message: 'Go Serverless v1.0! Your function executed successfully!', 10 | input: event, 11 | }), 12 | }; 13 | 14 | callback(null, response); 15 | } 16 | -------------------------------------------------------------------------------- /examples/individually/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "handler.js", 3 | "scripts": { 4 | "start": "sls offline" 5 | }, 6 | "dependencies": { 7 | "lodash": "^4.17.21" 8 | }, 9 | "devDependencies": { 10 | "@types/lodash": "4.14.185", 11 | "@types/node": "^18.7.21", 12 | "esbuild": "^0.24.0", 13 | "serverless": "^3.22.0", 14 | "serverless-esbuild": "file:../..", 15 | "serverless-offline": "^10.2.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/individually/plugins.js: -------------------------------------------------------------------------------- 1 | const envPlugin = { 2 | name: 'log-lodash', 3 | setup(build) { 4 | // test interception : log all lodash imports 5 | build.onResolve({ filter: /^lodash$/ }, (args) => { 6 | console.log(args); 7 | }); 8 | }, 9 | }; 10 | 11 | // default export should be an array of plugins 12 | module.exports = [envPlugin]; 13 | -------------------------------------------------------------------------------- /examples/individually/serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-example 2 | 3 | plugins: 4 | - serverless-esbuild 5 | - serverless-offline 6 | 7 | package: 8 | individually: true 9 | 10 | provider: 11 | name: aws 12 | runtime: nodejs18.x 13 | 14 | custom: 15 | esbuild: 16 | plugins: ./plugins.js 17 | packager: yarn 18 | bundle: true 19 | minify: true 20 | sourcemap: false 21 | keepNames: true 22 | external: 23 | - lodash 24 | 25 | functions: 26 | hello1: 27 | handler: hello1.handler 28 | events: 29 | - http: 30 | path: hello1 31 | method: get 32 | 33 | hello2: 34 | handler: hello2.handler 35 | events: 36 | - http: 37 | path: hello2 38 | method: get 39 | -------------------------------------------------------------------------------- /examples/minimal/.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .serverless 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /examples/minimal/README.md: -------------------------------------------------------------------------------- 1 | # [serverless-esbuild](../../README.md) minimal example 2 | 3 | This example shows how to use the `serverless-esbuild` plugin with default options. 4 | 5 | By default it bundles all dependencies in a single file and transpiles to the `ES2017` target. 6 | -------------------------------------------------------------------------------- /examples/minimal/index.js: -------------------------------------------------------------------------------- 1 | const validateIsin = require('isin-validator'); 2 | 3 | module.exports.handler = (event, context, callback) => { 4 | const isInvalid = validateIsin(event.pathParameters.isin); 5 | 6 | callback(null, { 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 | "scripts": { 5 | "start": "sls offline" 6 | }, 7 | "dependencies": { 8 | "isin-validator": "^1.1.1" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "^18.7.21", 12 | "esbuild": "^0.24.0", 13 | "serverless": "^3.22.0", 14 | "serverless-esbuild": "file:../..", 15 | "serverless-offline": "^10.2.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/minimal/serverless.yml: -------------------------------------------------------------------------------- 1 | service: minimal-example 2 | 3 | plugins: 4 | - serverless-esbuild 5 | - serverless-offline 6 | 7 | provider: 8 | name: aws 9 | runtime: nodejs18.x 10 | 11 | functions: 12 | validateIsin: 13 | handler: index.handler 14 | events: 15 | - http: 16 | path: validate-isin/{isin} 17 | method: get 18 | -------------------------------------------------------------------------------- /jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | const rootConfig = require('./jest.config'); 2 | 3 | const config = { ...rootConfig, rootDir: 'e2e' }; 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: 'ts-jest', 3 | verbose: true, 4 | transform: {}, 5 | testPathIgnorePatterns: ['dist'], 6 | rootDir: 'src', 7 | }; 8 | 9 | module.exports = config; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-esbuild", 3 | "version": "0.0.0-development", 4 | "description": "Serverless plugin for zero-config JavaScript and TypeScript code bundling using extremely fast esbuild", 5 | "keywords": [ 6 | "serverless", 7 | "serverless plugin", 8 | "plugin", 9 | "esbuild", 10 | "aws lambda", 11 | "aws", 12 | "lambda", 13 | "bundler", 14 | "minifier", 15 | "typescript" 16 | ], 17 | "homepage": "https://floydspace.github.io/serverless-esbuild", 18 | "bugs": { 19 | "url": "https://github.com/floydspace/serverless-esbuild/issues" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/floydspace/serverless-esbuild.git" 24 | }, 25 | "license": "MIT", 26 | "author": { 27 | "name": "Victor Korzunin", 28 | "url": "https://floydspace.github.io" 29 | }, 30 | "main": "dist/index.js", 31 | "files": [ 32 | "dist" 33 | ], 34 | "scripts": { 35 | "prepublishOnly": "npm run build", 36 | "precommit": "npm run test", 37 | "prebuild": "npm run clean", 38 | "build": "tsc -p ./tsconfig.build.json", 39 | "dev": "npm run build -- --watch", 40 | "typecheck": "tsc --noEmit", 41 | "clean": "rm -rf ./dist", 42 | "pretest": "npm run lint", 43 | "test": "jest --passWithNoTests", 44 | "test:e2e": "make -f e2e/Makefile", 45 | "lint": "eslint .", 46 | "prepare": "husky install" 47 | }, 48 | "lint-staged": { 49 | "*.ts": [ 50 | "prettier --write", 51 | "eslint" 52 | ] 53 | }, 54 | "prettier": "@floydspace/prettier-config", 55 | "dependencies": { 56 | "@effect/platform": "^0.65.5", 57 | "@effect/platform-node": "^0.60.5", 58 | "@effect/schema": "^0.73.4", 59 | "acorn": "^8.8.1", 60 | "acorn-walk": "^8.2.0", 61 | "anymatch": "^3.1.3", 62 | "archiver": "^5.3.1", 63 | "bestzip": "^2.2.1", 64 | "chokidar": "^3.5.3", 65 | "effect": "^3.8.3", 66 | "execa": "^5.1.1", 67 | "fs-extra": "^11.1.0", 68 | "globby": "^11.0.4", 69 | "p-map": "^4.0.0", 70 | "ramda": "^0.28.0", 71 | "semver": "^7.3.8" 72 | }, 73 | "devDependencies": { 74 | "@commitlint/cli": "^17.3.0", 75 | "@commitlint/config-conventional": "^17.3.0", 76 | "@floydspace/eslint-config": "^1.37.0", 77 | "@floydspace/prettier-config": "^1.37.0", 78 | "@types/archiver": "^5.3.1", 79 | "@types/fs-extra": "^9.0.13", 80 | "@types/jest": "^29.2.4", 81 | "@types/mock-fs": "^4.13.1", 82 | "@types/node": "^18.11.18", 83 | "@types/ramda": "^0.28.20", 84 | "@types/semver": "^7.3.13", 85 | "@types/serverless": "^3.12.14", 86 | "all-contributors-cli": "^6.24.0", 87 | "esbuild": "^0.25.0", 88 | "esbuild-node-externals": "^1.18.0", 89 | "eslint": "^8.56.0", 90 | "extract-zip": "^2.0.1", 91 | "husky": "^8.0.2", 92 | "jest": "^29.3.1", 93 | "lint-staged": "^13.1.0", 94 | "mock-fs": "^5.3.0", 95 | "mock-spawn": "^0.2.6", 96 | "prettier": "^2.8.1", 97 | "semantic-release": "^19.0.5", 98 | "ts-jest": "^29.2.5", 99 | "typescript": "~5.5.4" 100 | }, 101 | "peerDependencies": { 102 | "esbuild": "0.8 - 0.25", 103 | "esbuild-node-externals": "^1.0.0" 104 | }, 105 | "peerDependenciesMeta": { 106 | "esbuild-node-externals": { 107 | "optional": true 108 | } 109 | }, 110 | "engines": { 111 | "node": ">=18.0.0" 112 | }, 113 | "overrides": { 114 | "eslint": "$eslint" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/bundle.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Predicate } from 'effect'; 3 | import type { BuildOptions } from 'esbuild'; 4 | import * as pkg from 'esbuild'; 5 | import fs from 'fs-extra'; 6 | import pMap from 'p-map'; 7 | import path from 'path'; 8 | import { uniq } from 'ramda'; 9 | 10 | import type EsbuildServerlessPlugin from './index'; 11 | import { asArray, assertIsString, isESM } from './helper'; 12 | import type { EsbuildOptions, FileBuildResult, FunctionBuildResult, BuildContext } from './types'; 13 | import { trimExtension } from './utils'; 14 | 15 | const getStringArray = (input: unknown): string[] => asArray(input).filter(Predicate.isString); 16 | 17 | export async function bundle(this: EsbuildServerlessPlugin): Promise { 18 | assert(this.buildOptions, 'buildOptions is not defined'); 19 | 20 | this.prepare(); 21 | 22 | this.log.verbose(`Compiling to ${this.buildOptions?.target} bundle with esbuild...`); 23 | 24 | const exclude = getStringArray(this.buildOptions?.exclude); 25 | 26 | // esbuild v0.7.0 introduced config options validation, so I have to delete plugin specific options from esbuild config. 27 | const esbuildOptions: EsbuildOptions = [ 28 | 'concurrency', 29 | 'zipConcurrency', 30 | 'exclude', 31 | 'nativeZip', 32 | 'packager', 33 | 'packagePath', 34 | 'watch', 35 | 'keepOutputDirectory', 36 | 'packagerOptions', 37 | 'installExtraArgs', 38 | 'outputFileExtension', 39 | 'outputBuildFolder', 40 | 'outputWorkFolder', 41 | 'nodeExternals', 42 | 'skipBuild', 43 | 'skipRebuild', 44 | 'skipBuildExcludeFns', 45 | 'stripEntryResolveExtensions', 46 | 'disposeContext', 47 | ].reduce>((options, optionName) => { 48 | const { [optionName]: _, ...rest } = options; 49 | 50 | return rest; 51 | }, this.buildOptions); 52 | 53 | const config: Omit = { 54 | ...esbuildOptions, 55 | external: [...getStringArray(this.buildOptions?.external), ...(exclude.includes('*') ? [] : exclude)], 56 | plugins: this.plugins, 57 | }; 58 | 59 | const { buildOptions, buildDirPath } = this; 60 | 61 | assert(buildOptions, 'buildOptions is not defined'); 62 | 63 | assertIsString(buildDirPath, 'buildDirPath is not a string'); 64 | 65 | if (isESM(buildOptions) && buildOptions.outputFileExtension === '.cjs') { 66 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 67 | // @ts-ignore Serverless typings (as of v3.0.2) are incorrect 68 | throw new this.serverless.classes.Error( 69 | 'ERROR: format "esm" or platform "neutral" should not output a file with extension ".cjs".' 70 | ); 71 | } 72 | 73 | if (!isESM(buildOptions) && buildOptions.outputFileExtension === '.mjs') { 74 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 75 | // @ts-ignore Serverless typings (as of v3.0.2) are incorrect 76 | throw new this.serverless.classes.Error('ERROR: Non esm builds should not output a file with extension ".mjs".'); 77 | } 78 | 79 | if (buildOptions.outputFileExtension !== '.js') { 80 | config.outExtension = { '.js': buildOptions.outputFileExtension }; 81 | } 82 | 83 | /** Build the files */ 84 | const bundleMapper = async (entry: string): Promise => { 85 | const bundlePath = entry.slice(0, entry.lastIndexOf('.')) + buildOptions.outputFileExtension; 86 | 87 | // check cache 88 | if (this.buildCache) { 89 | const { result, context } = this.buildCache[entry] ?? {}; 90 | 91 | if (result?.rebuild) { 92 | await result.rebuild(); 93 | return { bundlePath, entry, result }; 94 | } 95 | 96 | if (context?.rebuild) { 97 | const rebuild = await context.rebuild(); 98 | return { bundlePath, entry, context, result: rebuild }; 99 | } 100 | } 101 | 102 | const options = { 103 | ...config, 104 | entryPoints: [entry], 105 | outdir: path.join(buildDirPath, path.dirname(entry)), 106 | }; 107 | 108 | type ContextFn = (opts: typeof options) => Promise; 109 | type WithContext = typeof pkg & { context?: ContextFn }; 110 | const context = buildOptions.skipRebuild ? undefined : await (pkg as WithContext).context?.(options); 111 | 112 | let result; 113 | if (!buildOptions.skipRebuild) { 114 | result = await context?.rebuild(); 115 | if (!result) { 116 | result = await pkg.build(options); 117 | } 118 | } else { 119 | result = await pkg.build(options); 120 | } 121 | 122 | if (config.metafile) { 123 | fs.writeFileSync( 124 | path.join(buildDirPath, `${trimExtension(entry)}-meta.json`), 125 | JSON.stringify(result.metafile, null, 2) 126 | ); 127 | } 128 | 129 | return { bundlePath, entry, result, context }; 130 | }; 131 | 132 | // Files can contain multiple handlers for multiple functions, we want to get only the unique ones 133 | const uniqueFiles: string[] = uniq(this.functionEntries.map(({ entry }) => entry)); 134 | 135 | this.log.verbose(`Compiling with concurrency: ${buildOptions.concurrency}`); 136 | 137 | const fileBuildResults = await pMap(uniqueFiles, bundleMapper, { 138 | concurrency: buildOptions.concurrency, 139 | }); 140 | 141 | // Create a cache with entry as key 142 | this.buildCache = fileBuildResults.reduce>((acc, fileBuildResult) => { 143 | acc[fileBuildResult.entry] = fileBuildResult; 144 | 145 | return acc; 146 | }, {}); 147 | 148 | // Map function entries back to bundles 149 | this.buildResults = this.functionEntries 150 | .map(({ entry, func, functionAlias }) => { 151 | const { bundlePath } = this.buildCache[entry] ?? {}; 152 | 153 | if (typeof bundlePath !== 'string' || func === null) { 154 | return; 155 | } 156 | 157 | return { bundlePath, func, functionAlias }; 158 | }) 159 | .filter((result): result is FunctionBuildResult => typeof result === 'object'); 160 | 161 | this.log.verbose('Compiling completed.'); 162 | } 163 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SERVERLESS_FOLDER = '.serverless'; 2 | export const BUILD_FOLDER = '.build'; 3 | export const WORK_FOLDER = '.esbuild'; 4 | export const ONLY_PREFIX = '__only_'; 5 | export const DEFAULT_EXTENSIONS = ['.ts', '.js', '.jsx', '.tsx']; 6 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bestzip' { 2 | export type BestZipOptions = { 3 | source: string; 4 | destination: string; 5 | cwd: string; 6 | }; 7 | 8 | export function bestzip(options: BestZipOptions): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | import assert, { AssertionError } from 'assert'; 2 | import { Predicate } from 'effect'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | 6 | import { parse } from 'acorn'; 7 | import { simple as simpleWalk } from 'acorn-walk'; 8 | import fs from 'fs-extra'; 9 | import { uniq } from 'ramda'; 10 | 11 | import type Serverless from 'serverless'; 12 | import type ServerlessPlugin from 'serverless/classes/Plugin'; 13 | import type { Configuration, DependencyMap, FunctionEntry, IFile } from './types'; 14 | import type { EsbuildFunctionDefinitionHandler } from './types'; 15 | import { DEFAULT_EXTENSIONS } from './constants'; 16 | 17 | export function asArray(data: T | T[]): T[] { 18 | return Array.isArray(data) ? data : [data]; 19 | } 20 | 21 | export function assertIsString(input: unknown, message = 'input is not a string'): asserts input is string { 22 | if (!Predicate.isString(input)) { 23 | throw new AssertionError({ message, actual: input }); 24 | } 25 | } 26 | 27 | export function extractFunctionEntries( 28 | cwd: string, 29 | provider: string, 30 | functions: Record, 31 | resolveExtensions?: string[] 32 | ): FunctionEntry[] { 33 | // The Google provider will use the entrypoint not from the definition of the 34 | // handler function, but instead from the package.json:main field, or via a 35 | // index.js file. This check reads the current package.json in the same way 36 | // that we already read the tsconfig.json file, by inspecting the current 37 | // working directory. If the packageFile does not contain a valid main, then 38 | // it instead selects the index.js file. 39 | if (provider === 'google') { 40 | const packageFilePath = path.join(cwd, 'package.json'); 41 | 42 | if (fs.existsSync(packageFilePath)) { 43 | // Load in the package.json file. 44 | const packageFile = JSON.parse(fs.readFileSync(packageFilePath).toString()); 45 | 46 | // Either grab the package.json:main field, or use the index.ts file. 47 | // (This will be transpiled to index.js). 48 | const entry = packageFile.main ? packageFile.main.replace(/\.js$/, '.ts') : 'index.ts'; 49 | 50 | // Check that the file indeed exists. 51 | if (!fs.existsSync(path.join(cwd, entry))) { 52 | throw new Error(`Compilation failed. Cannot locate entrypoint, ${entry} not found`); 53 | } 54 | 55 | return [{ entry, func: null }]; 56 | } 57 | } 58 | 59 | return Object.keys(functions) 60 | .filter((functionAlias) => { 61 | return !(functions[functionAlias] as EsbuildFunctionDefinitionHandler).skipEsbuild; 62 | }) 63 | .map((functionAlias) => { 64 | const func = functions[functionAlias] as EsbuildFunctionDefinitionHandler; 65 | assert(func, `${functionAlias} not found in functions`); 66 | 67 | const { handler, esbuildEntrypoint } = func; 68 | const entrypoint = esbuildEntrypoint || handler; 69 | 70 | const fnName = path.extname(entrypoint); 71 | const fnNameLastAppearanceIndex = entrypoint.lastIndexOf(fnName); 72 | // replace only last instance to allow the same name for file and handler 73 | const fileName = entrypoint.substring(0, fnNameLastAppearanceIndex); 74 | 75 | const extensions = resolveExtensions ?? DEFAULT_EXTENSIONS; 76 | 77 | for (const extension of extensions) { 78 | // Check if the .{extension} files exists. If so return that to watch 79 | if (fs.existsSync(path.join(cwd, fileName + extension))) { 80 | const entry = path.relative(cwd, fileName + extension); 81 | 82 | return { 83 | func, 84 | functionAlias, 85 | entry: os.platform() === 'win32' ? entry.replace(/\\/g, '/') : entry, 86 | }; 87 | } 88 | if (fs.existsSync(path.join(cwd, path.join(fileName, 'index') + extension))) { 89 | const entry = path.relative(cwd, path.join(fileName, 'index') + extension); 90 | 91 | return { 92 | func, 93 | functionAlias, 94 | entry: os.platform() === 'win32' ? entry.replace(/\\/g, '/') : entry, 95 | }; 96 | } 97 | } 98 | // Can't find the files. Watch will have an exception anyway. So throw one with error. 99 | throw new Error( 100 | `Compilation failed for function alias ${functionAlias}. Please ensure you have an index file with ext .ts or .js, or have a path listed as main key in package.json` 101 | ); 102 | }); 103 | } 104 | 105 | /** 106 | * Takes a dependency graph and returns a flat list of required production dependencies for all or the filtered deps 107 | * @param root the root of the dependency tree 108 | * @param rootDeps array of top level root dependencies to whitelist 109 | */ 110 | export const flatDep = (root: DependencyMap, rootDepsFilter: string[]): string[] => { 111 | const flattenedDependencies = new Set(); 112 | 113 | /** 114 | * 115 | * @param deps the current tree 116 | * @param filter the dependencies to get from this tree 117 | */ 118 | const recursiveFind = (deps: DependencyMap | undefined, filter?: string[]) => { 119 | if (!deps) return; 120 | 121 | Object.entries(deps).forEach(([depName, details]) => { 122 | // only for root level dependencies 123 | if (filter && !filter.includes(depName)) { 124 | return; 125 | } 126 | 127 | if (details.isRootDep || filter) { 128 | // We already have this root dep and it's dependencies - skip this iteration 129 | if (flattenedDependencies.has(depName)) { 130 | return; 131 | } 132 | 133 | flattenedDependencies.add(depName); 134 | 135 | const dep = root[depName]; 136 | 137 | dep && recursiveFind(dep.dependencies); 138 | 139 | return; 140 | } 141 | 142 | // This is a nested dependency and will be included by default when we include it's parent 143 | // We just need to check if we fulfil all it's dependencies 144 | recursiveFind(details.dependencies); 145 | }); 146 | }; 147 | 148 | recursiveFind(root, rootDepsFilter); 149 | 150 | return Array.from(flattenedDependencies); 151 | }; 152 | 153 | /** 154 | * Extracts the base package from a package string taking scope into consideration 155 | * @example getBaseDep('@scope/package/register') returns '@scope/package' 156 | * @example getBaseDep('package/register') returns 'package' 157 | * @example getBaseDep('package') returns 'package' 158 | * @param input 159 | */ 160 | const getBaseDep = (input: string): string | undefined => { 161 | const result = /^@[^/]+\/[^/\n]+|^[^/\n]+/.exec(input); 162 | 163 | if (Array.isArray(result) && result[0]) { 164 | return result[0]; 165 | } 166 | }; 167 | 168 | export const isESM = (buildOptions: Configuration): boolean => { 169 | return buildOptions.format === 'esm' || (buildOptions.platform === 'neutral' && !buildOptions.format); 170 | }; 171 | 172 | /** 173 | * Extracts the list of dependencies that appear in a bundle as `import 'XXX'`, `import('XXX')`, or `require('XXX')`. 174 | * @param bundlePath Absolute path to a bundled JS file 175 | * @param useESM Should the bundle be treated as ESM 176 | */ 177 | export const getDepsFromBundle = (bundlePath: string, useESM: boolean): string[] => { 178 | const bundleContent = fs.readFileSync(bundlePath, 'utf8'); 179 | const deps: string[] = []; 180 | 181 | const ast = parse(bundleContent, { 182 | ecmaVersion: 'latest', 183 | sourceType: useESM ? 'module' : 'script', 184 | }); 185 | 186 | // I'm using `node: any` since the type definition is not accurate. 187 | // There are properties at runtime that do not exist in the `acorn.Node` type. 188 | simpleWalk(ast, { 189 | CallExpression(node: any) { 190 | if (node.callee.name === 'require') { 191 | deps.push(node.arguments[0].value); 192 | } 193 | }, 194 | ImportExpression(node: any) { 195 | deps.push(node.source.value); 196 | }, 197 | ImportDeclaration(node: any) { 198 | deps.push(node.source.value); 199 | }, 200 | }); 201 | 202 | const baseDeps = deps.map(getBaseDep).filter(Predicate.isString); 203 | 204 | return uniq(baseDeps); 205 | }; 206 | 207 | export const doSharePath = (child: string, parent: string): boolean => { 208 | if (child === parent) { 209 | return true; 210 | } 211 | 212 | const parentTokens = parent.split('/'); 213 | const childToken = child.split('/'); 214 | 215 | return parentTokens.every((token, index) => childToken[index] === token); 216 | }; 217 | 218 | export type AwsNodeProviderRuntimeMatcher = { 219 | [Version in Versions as `nodejs${Version}.x`]: `node${Version}`; 220 | }; 221 | 222 | export type AzureNodeProviderRuntimeMatcher = { 223 | [Version in Versions as `nodejs${Version}`]: `node${Version}`; 224 | }; 225 | 226 | export type GoogleNodeProviderRuntimeMatcher = { 227 | [Version in Versions as `nodejs${Version}`]: `node${Version}`; 228 | }; 229 | 230 | export type ScalewayNodeProviderRuntimeMatcher = { 231 | [Version in Versions as `node${Version}`]: `node${Version}`; 232 | }; 233 | 234 | export type AwsNodeMatcher = AwsNodeProviderRuntimeMatcher<12 | 14 | 16 | 18 | 20 | 22>; 235 | 236 | export type AzureNodeMatcher = AzureNodeProviderRuntimeMatcher<12 | 14 | 16 | 18>; 237 | 238 | export type GoogleNodeMatcher = GoogleNodeProviderRuntimeMatcher<12 | 14 | 16 | 18 | 20>; 239 | 240 | export type ScalewayNodeMatcher = ScalewayNodeProviderRuntimeMatcher<12 | 14 | 16 | 18 | 20>; 241 | 242 | export type NodeMatcher = AwsNodeMatcher & AzureNodeMatcher & GoogleNodeMatcher & ScalewayNodeMatcher; 243 | 244 | export type AwsNodeMatcherKey = keyof AwsNodeMatcher; 245 | 246 | export type AzureNodeMatcherKey = keyof AzureNodeMatcher; 247 | 248 | export type GoogleNodeMatcherKey = keyof GoogleNodeMatcher; 249 | 250 | export type ScalewayNodeMatcherKey = keyof ScalewayNodeMatcher; 251 | 252 | export type NodeMatcherKey = AwsNodeMatcherKey | AzureNodeMatcherKey | GoogleNodeMatcherKey | ScalewayNodeMatcherKey; 253 | 254 | const awsNodeMatcher: AwsNodeMatcher = { 255 | 'nodejs22.x': 'node22', 256 | 'nodejs20.x': 'node20', 257 | 'nodejs18.x': 'node18', 258 | 'nodejs16.x': 'node16', 259 | 'nodejs14.x': 'node14', 260 | 'nodejs12.x': 'node12', 261 | }; 262 | 263 | const azureNodeMatcher: AzureNodeMatcher = { 264 | nodejs18: 'node18', 265 | nodejs16: 'node16', 266 | nodejs14: 'node14', 267 | nodejs12: 'node12', 268 | }; 269 | 270 | const googleNodeMatcher: GoogleNodeMatcher = { 271 | nodejs20: 'node20', 272 | nodejs18: 'node18', 273 | nodejs16: 'node16', 274 | nodejs14: 'node14', 275 | nodejs12: 'node12', 276 | }; 277 | 278 | const scalewayNodeMatcher: ScalewayNodeMatcher = { 279 | node20: 'node20', 280 | node18: 'node18', 281 | node16: 'node16', 282 | node14: 'node14', 283 | node12: 'node12', 284 | }; 285 | 286 | const nodeMatcher: NodeMatcher = { 287 | ...googleNodeMatcher, 288 | ...awsNodeMatcher, 289 | ...azureNodeMatcher, 290 | ...scalewayNodeMatcher, 291 | }; 292 | 293 | export const providerRuntimeMatcher = Object.freeze>({ 294 | aws: awsNodeMatcher as NodeMatcher, 295 | azure: azureNodeMatcher as NodeMatcher, 296 | google: googleNodeMatcher as NodeMatcher, 297 | scaleway: scalewayNodeMatcher as NodeMatcher, 298 | }); 299 | 300 | export const isNodeMatcherKey = (input: unknown): input is NodeMatcherKey => 301 | typeof input === 'string' && Object.keys(nodeMatcher).includes(input); 302 | 303 | export function assertIsSupportedRuntime(input: unknown): asserts input is NodeMatcherKey { 304 | if (!isNodeMatcherKey(input)) { 305 | throw new AssertionError({ actual: input, message: 'not a supported runtime' }); 306 | } 307 | } 308 | 309 | export const buildServerlessV3LoggerFromLegacyLogger = ( 310 | legacyLogger: Serverless['cli'], 311 | verbose?: boolean 312 | ): ServerlessPlugin.Logging['log'] => ({ 313 | error: legacyLogger.log.bind(legacyLogger), 314 | warning: legacyLogger.log.bind(legacyLogger), 315 | notice: legacyLogger.log.bind(legacyLogger), 316 | info: legacyLogger.log.bind(legacyLogger), 317 | debug: verbose ? legacyLogger.log.bind(legacyLogger) : () => null, 318 | verbose: legacyLogger.log.bind(legacyLogger), 319 | success: legacyLogger.log.bind(legacyLogger), 320 | }); 321 | 322 | export const stripEntryResolveExtensions = (file: IFile, extensions: string[]): IFile => { 323 | const resolveExtensionMatch = file.localPath.match(extensions.map((ext) => ext).join('|')); 324 | 325 | if (resolveExtensionMatch?.length && !DEFAULT_EXTENSIONS.includes(resolveExtensionMatch[0])) { 326 | const extensionParts = resolveExtensionMatch[0].split('.'); 327 | 328 | return { 329 | ...file, 330 | localPath: file.localPath.replace(resolveExtensionMatch[0], `.${extensionParts[extensionParts.length - 1]}`), 331 | }; 332 | } 333 | 334 | return file; 335 | }; 336 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import { Predicate } from 'effect'; 4 | import fs from 'fs-extra'; 5 | import globby from 'globby'; 6 | 7 | import { concat, mergeDeepRight } from 'ramda'; 8 | import type Serverless from 'serverless'; 9 | import type ServerlessPlugin from 'serverless/classes/Plugin'; 10 | import chokidar from 'chokidar'; 11 | import anymatch from 'anymatch'; 12 | 13 | import { 14 | asArray, 15 | assertIsString, 16 | assertIsSupportedRuntime, 17 | buildServerlessV3LoggerFromLegacyLogger, 18 | extractFunctionEntries, 19 | isNodeMatcherKey, 20 | providerRuntimeMatcher, 21 | } from './helper'; 22 | import { packExternalModules } from './pack-externals'; 23 | import { pack, copyPreBuiltResources } from './pack'; 24 | import { preOffline } from './pre-offline'; 25 | import { preLocal } from './pre-local'; 26 | import { bundle } from './bundle'; 27 | import { BUILD_FOLDER, ONLY_PREFIX, SERVERLESS_FOLDER, WORK_FOLDER } from './constants'; 28 | import type { 29 | ConfigFn, 30 | Configuration, 31 | EsbuildFunctionDefinitionHandler, 32 | FileBuildResult, 33 | FunctionBuildResult, 34 | ImprovedServerlessOptions, 35 | Plugins, 36 | ReturnPluginsFn, 37 | ESMPluginsModule, 38 | } from './types'; 39 | import { isESMModule } from './utils'; 40 | 41 | function updateFile(op: string, src: string, dest: string) { 42 | if (['add', 'change', 'addDir'].includes(op)) { 43 | fs.copySync(src, dest, { 44 | dereference: true, 45 | errorOnExist: false, 46 | preserveTimestamps: true, 47 | recursive: true, 48 | }); 49 | 50 | return; 51 | } 52 | 53 | if (['unlink', 'unlinkDir'].includes(op)) { 54 | fs.removeSync(dest); 55 | } 56 | } 57 | 58 | class EsbuildServerlessPlugin implements ServerlessPlugin { 59 | serviceDirPath: string; 60 | 61 | outputWorkFolder: string | undefined; 62 | 63 | workDirPath: string | undefined; 64 | 65 | outputBuildFolder: string | undefined; 66 | 67 | buildDirPath: string | undefined; 68 | 69 | packageOutputPath: string = SERVERLESS_FOLDER; 70 | 71 | log: ServerlessPlugin.Logging['log']; 72 | 73 | serverless: Serverless; 74 | 75 | options: ImprovedServerlessOptions; 76 | 77 | hooks: ServerlessPlugin.Hooks; 78 | 79 | buildOptions: Configuration | undefined; 80 | 81 | buildResults: FunctionBuildResult[] | undefined; 82 | 83 | /** Used for storing previous esbuild build results so we can rebuild more efficiently */ 84 | buildCache: Record = {}; 85 | 86 | // These are bound to imported functions. 87 | packExternalModules: typeof packExternalModules; 88 | 89 | pack: typeof pack; 90 | 91 | copyPreBuiltResources: typeof copyPreBuiltResources; 92 | 93 | preOffline: typeof preOffline; 94 | 95 | preLocal: typeof preLocal; 96 | 97 | bundle: typeof bundle; 98 | 99 | constructor(serverless: Serverless, options: ImprovedServerlessOptions, logging?: ServerlessPlugin.Logging) { 100 | this.serverless = serverless; 101 | this.options = options; 102 | this.log = logging?.log || buildServerlessV3LoggerFromLegacyLogger(this.serverless.cli, this.options.verbose); 103 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 104 | // @ts-ignore old versions use servicePath, new versions serviceDir. Types will use only one of them 105 | this.serviceDirPath = this.serverless.config.serviceDir || this.serverless.config.servicePath; 106 | 107 | this.packExternalModules = packExternalModules.bind(this); 108 | this.pack = pack.bind(this); 109 | this.copyPreBuiltResources = copyPreBuiltResources.bind(this); 110 | this.preOffline = preOffline.bind(this); 111 | this.preLocal = preLocal.bind(this); 112 | this.bundle = bundle.bind(this); 113 | 114 | // This tells serverless that this skipEsbuild property can exist in a function definition, but isn't required. 115 | // That way a user could skip a function if they have defined their own artifact, for example. 116 | this.serverless.configSchemaHandler.defineFunctionProperties(this.serverless.service.provider.name, { 117 | properties: { 118 | skipEsbuild: { type: 'boolean' }, 119 | }, 120 | }); 121 | 122 | this.hooks = { 123 | initialize: async () => { 124 | this.init(); 125 | if (this.buildOptions?.skipBuild) { 126 | this.prepare(); 127 | await this.copyPreBuiltResources(); 128 | } 129 | }, 130 | 'before:run:run': async () => { 131 | this.log.verbose('before:run:run'); 132 | await this.bundle(); 133 | await this.packExternalModules(); 134 | await this.copyExtras(); 135 | }, 136 | 'before:offline:start': async () => { 137 | this.log.verbose('before:offline:start'); 138 | await this.bundle(); 139 | await this.packExternalModules(); 140 | await this.copyExtras(); 141 | await this.preOffline(); 142 | this.watch(); 143 | }, 144 | 'before:offline:start:init': async () => { 145 | this.log.verbose('before:offline:start:init'); 146 | await this.bundle(); 147 | await this.packExternalModules(); 148 | await this.copyExtras(); 149 | await this.preOffline(); 150 | this.watch(); 151 | }, 152 | 'before:package:createDeploymentArtifacts': async () => { 153 | this.log.verbose('before:package:createDeploymentArtifacts'); 154 | if (this.functionEntries?.length > 0) { 155 | await this.bundle(); 156 | await this.packExternalModules(); 157 | await this.copyExtras(); 158 | await this.pack(); 159 | } 160 | }, 161 | 'after:package:createDeploymentArtifacts': async () => { 162 | this.log.verbose('after:package:createDeploymentArtifacts'); 163 | await this.disposeContexts(); 164 | await this.cleanup(); 165 | }, 166 | 'before:deploy:function:packageFunction': async () => { 167 | this.log.verbose('after:deploy:function:packageFunction'); 168 | await this.bundle(); 169 | await this.packExternalModules(); 170 | await this.copyExtras(); 171 | await this.pack(); 172 | }, 173 | 'after:deploy:function:packageFunction': async () => { 174 | this.log.verbose('after:deploy:function:packageFunction'); 175 | await this.disposeContexts(); 176 | await this.cleanup(); 177 | }, 178 | 'before:invoke:local:invoke': async () => { 179 | this.log.verbose('before:invoke:local:invoke'); 180 | await this.bundle(); 181 | await this.packExternalModules(); 182 | await this.copyExtras(); 183 | await this.preLocal(); 184 | }, 185 | 'after:invoke:local:invoke': async () => { 186 | await this.disposeContexts(); 187 | }, 188 | }; 189 | } 190 | 191 | private init() { 192 | this.buildOptions = this.getBuildOptions(); 193 | this.outputWorkFolder = this.buildOptions.outputWorkFolder || WORK_FOLDER; 194 | this.outputBuildFolder = this.buildOptions.outputBuildFolder || BUILD_FOLDER; 195 | this.packageOutputPath = this.options.package || SERVERLESS_FOLDER; 196 | this.workDirPath = path.join(this.serviceDirPath, this.outputWorkFolder); 197 | this.buildDirPath = path.join(this.workDirPath, this.outputBuildFolder); 198 | } 199 | 200 | /** 201 | * Checks if the runtime for the given function is nodejs. 202 | * If the runtime is not set , checks the global runtime. 203 | * @param {Serverless.FunctionDefinitionHandler} func the function to be checked 204 | * @returns {boolean} true if the function/global runtime is nodejs; false, otherwise 205 | */ 206 | private isNodeFunction(func: Serverless.FunctionDefinitionHandler): boolean { 207 | const runtime = func.runtime || this.serverless.service.provider.runtime; 208 | const runtimeMatcher = providerRuntimeMatcher[this.serverless.service.provider.name]; 209 | 210 | return isNodeMatcherKey(runtime) && typeof runtimeMatcher?.[runtime] === 'string'; 211 | } 212 | 213 | /** 214 | * Checks if the function has a handler 215 | * @param {Serverless.FunctionDefinitionHandler | Serverless.FunctionDefinitionImage} func the function to be checked 216 | * @returns {boolean} true if the function has a handler 217 | */ 218 | private isFunctionDefinitionHandler( 219 | func: Serverless.FunctionDefinitionHandler | Serverless.FunctionDefinitionImage 220 | ): func is Serverless.FunctionDefinitionHandler { 221 | return Boolean((func as Serverless.FunctionDefinitionHandler)?.handler); 222 | } 223 | 224 | get functions(): Record { 225 | const functions = this.options.function 226 | ? { 227 | [this.options.function]: this.serverless.service.getFunction(this.options.function), 228 | } 229 | : this.serverless.service.functions; 230 | 231 | const buildOptions = this.getBuildOptions(); 232 | // ignore all functions with a different runtime than nodejs: 233 | const nodeFunctions: Record = {}; 234 | 235 | for (const [functionAlias, fn] of Object.entries(functions)) { 236 | const currFn = fn as EsbuildFunctionDefinitionHandler; 237 | if (this.isFunctionDefinitionHandler(currFn) && this.isNodeFunction(currFn)) { 238 | buildOptions.disposeContext = currFn.disposeContext ? currFn.disposeContext : buildOptions.disposeContext; // disposeContext configuration can be overridden per function 239 | if (buildOptions.skipBuild && !buildOptions.skipBuildExcludeFns?.includes(functionAlias)) { 240 | currFn.skipEsbuild = true; 241 | } 242 | 243 | nodeFunctions[functionAlias] = currFn; 244 | } 245 | } 246 | 247 | return nodeFunctions; 248 | } 249 | 250 | get plugins(): Plugins { 251 | if (!this.buildOptions?.plugins) { 252 | return []; 253 | } 254 | 255 | if (Array.isArray(this.buildOptions.plugins)) { 256 | return this.buildOptions.plugins; 257 | } 258 | 259 | let plugins: Plugins | ReturnPluginsFn | ESMPluginsModule = require(path.join( 260 | this.serviceDirPath, 261 | this.buildOptions.plugins 262 | )); 263 | 264 | if (isESMModule(plugins)) { 265 | plugins = plugins.default; 266 | } 267 | 268 | if (typeof plugins === 'function') { 269 | return plugins(this.serverless); 270 | } 271 | 272 | return plugins; 273 | } 274 | 275 | get packagePatterns() { 276 | const { service } = this.serverless; 277 | const patterns: string[] = []; 278 | const ignored: string[] = []; 279 | 280 | for (const pattern of service.package.patterns) { 281 | if (pattern.startsWith('!')) { 282 | ignored.push(pattern.slice(1)); 283 | } else { 284 | patterns.push(pattern); 285 | } 286 | } 287 | 288 | for (const fn of Object.values(this.functions)) { 289 | const fnPatterns = asArray(fn.package?.patterns).filter(Predicate.isString); 290 | 291 | for (const pattern of fnPatterns) { 292 | if (pattern.startsWith('!')) { 293 | ignored.push(pattern.slice(1)); 294 | } else { 295 | patterns.push(pattern); 296 | } 297 | } 298 | } 299 | 300 | return { patterns, ignored }; 301 | } 302 | 303 | private getBuildOptions() { 304 | if (this.buildOptions) return this.buildOptions; 305 | 306 | const DEFAULT_BUILD_OPTIONS: Partial = { 307 | concurrency: Infinity, 308 | zipConcurrency: Infinity, 309 | bundle: true, 310 | target: 'node18', 311 | external: [], 312 | exclude: ['aws-sdk'], 313 | nativeZip: false, 314 | packager: 'npm', 315 | packagerOptions: { 316 | noInstall: false, 317 | ignoreLockfile: false, 318 | }, 319 | installExtraArgs: [], 320 | watch: { 321 | pattern: './**/*.(js|ts)', 322 | ignore: [WORK_FOLDER, 'dist', 'node_modules', BUILD_FOLDER], 323 | chokidar: { 324 | ignoreInitial: true, 325 | }, 326 | }, 327 | keepOutputDirectory: false, 328 | platform: 'node', 329 | outputFileExtension: '.js', 330 | skipBuild: false, 331 | skipBuildExcludeFns: [], 332 | stripEntryResolveExtensions: false, 333 | disposeContext: true, // default true 334 | }; 335 | 336 | const providerRuntime = this.serverless.service.provider.runtime; 337 | 338 | assertIsSupportedRuntime(providerRuntime); 339 | 340 | const runtimeMatcher = providerRuntimeMatcher[this.serverless.service.provider.name]; 341 | const target = isNodeMatcherKey(providerRuntime) ? runtimeMatcher?.[providerRuntime] : undefined; 342 | 343 | const resolvedOptions = { 344 | ...(target ? { target } : {}), 345 | }; 346 | const withDefaultOptions = mergeDeepRight(DEFAULT_BUILD_OPTIONS); 347 | const withResolvedOptions = mergeDeepRight(withDefaultOptions(resolvedOptions)); 348 | 349 | const configPath: string | undefined = this.serverless.service.custom?.esbuild?.config; 350 | 351 | const config: ConfigFn | undefined = configPath ? require(path.join(this.serviceDirPath, configPath)) : undefined; 352 | 353 | return withResolvedOptions( 354 | config ? config(this.serverless) : this.serverless.service.custom?.esbuild ?? {} 355 | ) as Configuration; 356 | } 357 | 358 | get functionEntries() { 359 | return extractFunctionEntries( 360 | this.serviceDirPath, 361 | this.serverless.service.provider.name, 362 | this.functions, 363 | this.buildOptions?.resolveExtensions 364 | ); 365 | } 366 | 367 | watch(): void { 368 | assert(this.buildOptions, 'buildOptions is not defined'); 369 | 370 | const defaultPatterns = asArray(this.buildOptions.watch.pattern).filter(Predicate.isString); 371 | const defaultIgnored = asArray(this.buildOptions.watch.ignore).filter(Predicate.isString); 372 | 373 | const { patterns, ignored } = this.packagePatterns; 374 | 375 | const allPatterns: string[] = [...defaultPatterns, ...patterns]; 376 | const allIgnored: string[] = [...defaultIgnored, ...ignored]; 377 | 378 | const options = { 379 | ignored: allIgnored, 380 | ...this.buildOptions.watch.chokidar, 381 | }; 382 | 383 | chokidar.watch(allPatterns, options).on('all', (eventName, srcPath) => 384 | this.bundle() 385 | .then(() => this.updateFile(eventName, srcPath)) 386 | .then(() => this.notifyServerlessOffline()) 387 | .then(() => this.log.verbose('Watching files for changes...')) 388 | .catch(() => this.log.error('Bundle error, waiting for a file change to reload...')) 389 | ); 390 | } 391 | 392 | prepare() { 393 | assertIsString(this.buildDirPath, 'buildDirPath is not a string'); 394 | assertIsString(this.workDirPath, 'workDirPath is not a string'); 395 | 396 | fs.mkdirpSync(this.buildDirPath); 397 | fs.mkdirpSync(path.join(this.workDirPath, SERVERLESS_FOLDER)); 398 | // exclude serverless-esbuild 399 | this.serverless.service.package = { 400 | ...(this.serverless.service.package || {}), 401 | patterns: [ 402 | ...new Set([ 403 | ...(this.serverless.service.package?.include || []), 404 | ...(this.serverless.service.package?.exclude || []).map(concat('!')), 405 | ...(this.serverless.service.package?.patterns || []), 406 | '!node_modules/serverless-esbuild', 407 | ]), 408 | ], 409 | }; 410 | 411 | for (const fn of Object.values(this.functions)) { 412 | const patterns = [ 413 | ...new Set([ 414 | ...(fn.package?.include || []), 415 | ...(fn.package?.exclude || []).map(concat('!')), 416 | ...(fn.package?.patterns || []), 417 | ]), 418 | ]; 419 | 420 | fn.package = { 421 | ...(fn.package || {}), 422 | ...(patterns.length && { patterns }), 423 | }; 424 | } 425 | } 426 | 427 | notifyServerlessOffline() { 428 | this.serverless.pluginManager.spawn('offline:functionsUpdated'); 429 | } 430 | 431 | async updateFile(op: string, filename: string) { 432 | assertIsString(this.buildDirPath, 'buildDirPath is not a string'); 433 | 434 | const { service } = this.serverless; 435 | 436 | const patterns = asArray(service.package.patterns).filter(Predicate.isString); 437 | 438 | if ( 439 | patterns.length > 0 && 440 | anymatch( 441 | patterns.filter((pattern) => !pattern.startsWith('!')), 442 | filename 443 | ) 444 | ) { 445 | const destFileName = path.resolve(path.join(this.buildDirPath, filename)); 446 | 447 | updateFile(op, path.resolve(filename), destFileName); 448 | 449 | return; 450 | } 451 | 452 | for (const [functionAlias, fn] of Object.entries(this.functions)) { 453 | if (fn.package?.patterns?.length === 0) { 454 | continue; 455 | } 456 | 457 | if ( 458 | anymatch( 459 | asArray(fn.package?.patterns) 460 | .filter(Predicate.isString) 461 | .filter((pattern) => !pattern.startsWith('!')), 462 | filename 463 | ) 464 | ) { 465 | const destFileName = path.resolve(path.join(this.buildDirPath, `${ONLY_PREFIX}${functionAlias}`, filename)); 466 | 467 | updateFile(op, path.resolve(filename), destFileName); 468 | 469 | return; 470 | } 471 | } 472 | } 473 | 474 | /** Link or copy extras such as node_modules or package.patterns definitions */ 475 | async copyExtras() { 476 | assertIsString(this.buildDirPath, 'buildDirPath is not a string'); 477 | 478 | const { service } = this.serverless; 479 | 480 | const packagePatterns = asArray(service.package.patterns).filter(Predicate.isString); 481 | 482 | // include any "extras" from the "patterns" section 483 | if (packagePatterns.length) { 484 | const files = await globby(packagePatterns); 485 | 486 | for (const filename of files) { 487 | const destFileName = path.resolve(path.join(this.buildDirPath, filename)); 488 | 489 | updateFile('add', path.resolve(filename), destFileName); 490 | } 491 | } 492 | 493 | // include any "extras" from the individual function "patterns" section 494 | for (const [functionAlias, fn] of Object.entries(this.functions)) { 495 | const patterns = asArray(fn.package?.patterns).filter(Predicate.isString); 496 | 497 | if (!patterns.length) { 498 | continue; 499 | } 500 | 501 | const files = await globby(patterns); 502 | 503 | for (const filename of files) { 504 | const destFileName = path.resolve(path.join(this.buildDirPath, `${ONLY_PREFIX}${functionAlias}`, filename)); 505 | 506 | updateFile('add', path.resolve(filename), destFileName); 507 | } 508 | } 509 | } 510 | 511 | /** 512 | * Move built code to the serverless folder, taking into account individual 513 | * packaging preferences. 514 | */ 515 | async moveArtifacts(): Promise { 516 | assertIsString(this.workDirPath, 'workDirPath is not a string'); 517 | 518 | const { service } = this.serverless; 519 | 520 | await fs.copy(path.join(this.workDirPath, SERVERLESS_FOLDER), path.join(this.serviceDirPath, SERVERLESS_FOLDER)); 521 | 522 | if (service.package.individually === true || this.options.function) { 523 | Object.values(this.functions).forEach((func) => { 524 | if (func.package?.artifact) { 525 | // eslint-disable-next-line no-param-reassign 526 | func.package.artifact = path.join(SERVERLESS_FOLDER, path.basename(func.package.artifact)); 527 | } 528 | }); 529 | 530 | return; 531 | } 532 | 533 | service.package.artifact = path.join(SERVERLESS_FOLDER, path.basename(service.package.artifact)); 534 | } 535 | 536 | async disposeContexts(): Promise { 537 | for (const { context } of Object.values(this.buildCache)) { 538 | if (context) { 539 | this.buildOptions?.disposeContext && (await context.dispose()); 540 | } 541 | } 542 | } 543 | 544 | async cleanup(): Promise { 545 | await this.moveArtifacts(); 546 | 547 | // Remove temp build folder 548 | if (!this.buildOptions?.keepOutputDirectory) { 549 | assertIsString(this.workDirPath, 'workDirPath is not a string'); 550 | 551 | fs.removeSync(path.join(this.workDirPath)); 552 | } 553 | } 554 | } 555 | 556 | export = EsbuildServerlessPlugin; 557 | -------------------------------------------------------------------------------- /src/pack-externals.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import path from 'path'; 3 | import fse from 'fs-extra'; 4 | import * as R from 'ramda'; 5 | import type { 6 | createAllowPredicate as CreateAllowPredicateFn, 7 | findDependencies as FindDependenciesFn, 8 | findPackagePaths as FindPackagePathsFn, 9 | } from 'esbuild-node-externals/dist/utils'; 10 | 11 | import { getPackager } from './packagers'; 12 | import { findProjectRoot, findUp } from './utils'; 13 | import type EsbuildServerlessPlugin from './index'; 14 | import type { JSONObject, PackageJSON } from './types'; 15 | import { assertIsString } from './helper'; 16 | 17 | function rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { 18 | if (/^(?:file:[^/]{2}|\.\/|\.\.\/)/.test(moduleVersion)) { 19 | const filePath = R.replace(/^file:/, '', moduleVersion); 20 | 21 | return R.replace( 22 | /\\/g, 23 | '/', 24 | `${R.startsWith('file:', moduleVersion) ? 'file:' : ''}${pathToPackageRoot}/${filePath}` 25 | ); 26 | } 27 | 28 | return moduleVersion; 29 | } 30 | 31 | /** 32 | * Add the given modules to a package json's dependencies. 33 | */ 34 | function addModulesToPackageJson(externalModules: string[], packageJson: JSONObject, pathToPackageRoot: string) { 35 | R.forEach((externalModule) => { 36 | const splitModule = R.split('@', externalModule); 37 | 38 | // If we have a scoped module we have to re-add the @ 39 | if (R.startsWith('@', externalModule)) { 40 | splitModule.splice(0, 1); 41 | splitModule[0] = `@${splitModule[0]}`; 42 | } 43 | 44 | const dependencyName = R.head(splitModule); 45 | 46 | if (!dependencyName) { 47 | return; 48 | } 49 | 50 | // We have to rebase file references to the target package.json 51 | const moduleVersion = rebaseFileReferences(pathToPackageRoot, R.join('@', R.tail(splitModule))); 52 | 53 | // eslint-disable-next-line no-param-reassign 54 | packageJson.dependencies = packageJson.dependencies || {}; 55 | // eslint-disable-next-line no-param-reassign 56 | packageJson.dependencies[dependencyName] = moduleVersion; 57 | }, externalModules); 58 | } 59 | 60 | /** 61 | * Resolve the needed versions of production dependencies for external modules. 62 | * @this - The active plugin instance 63 | */ 64 | function getProdModules( 65 | this: EsbuildServerlessPlugin, 66 | externalModules: { external: string }[], 67 | packageJsonPath: string, 68 | rootPackageJsonPath: string 69 | ) { 70 | const packageJson = this.serverless.utils.readFileSync(packageJsonPath) as PackageJSON; 71 | 72 | // only process the module stated in dependencies section 73 | if (!packageJson.dependencies) { 74 | return []; 75 | } 76 | 77 | const prodModules: string[] = []; 78 | 79 | // Get versions of all transient modules 80 | // eslint-disable-next-line max-statements 81 | R.forEach((externalModule) => { 82 | // (1) If not present in Dev Dependencies or Dependencies 83 | if ( 84 | !packageJson.dependencies?.[externalModule.external] && 85 | !packageJson.devDependencies?.[externalModule.external] 86 | ) { 87 | this.log.debug( 88 | `INFO: Runtime dependency '${externalModule.external}' not found in dependencies or devDependencies. It has been excluded automatically.` 89 | ); 90 | 91 | return; 92 | } 93 | 94 | // (2) If present in Dev Dependencies 95 | if ( 96 | !packageJson.dependencies?.[externalModule.external] && 97 | packageJson.devDependencies?.[externalModule.external] 98 | ) { 99 | // To minimize the chance of breaking setups we whitelist packages available on AWS here. These are due to the previously missing check 100 | // most likely set in devDependencies and should not lead to an error now. 101 | const ignoredDevDependencies = ['aws-sdk']; 102 | 103 | if (!R.includes(externalModule.external, ignoredDevDependencies)) { 104 | // Runtime dependency found in devDependencies but not forcefully excluded 105 | this.log.error(`ERROR: Runtime dependency '${externalModule.external}' found in devDependencies.`); 106 | 107 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 108 | // @ts-ignore Serverless typings (as of v3.0.2) are incorrect 109 | throw new this.serverless.classes.Error(`Serverless-webpack dependency error: ${externalModule.external}.`); 110 | } 111 | 112 | this.log.debug( 113 | `INFO: Runtime dependency '${externalModule.external}' found in devDependencies. It has been excluded automatically.` 114 | ); 115 | 116 | return; 117 | } 118 | 119 | // (3) otherwise let's get the version 120 | 121 | // get module package - either from root or local node_modules - will be used for version and peer deps 122 | const rootModulePackagePath = path.join( 123 | path.dirname(rootPackageJsonPath), 124 | 'node_modules', 125 | externalModule.external, 126 | 'package.json' 127 | ); 128 | 129 | const localModulePackagePath = path.join( 130 | process.cwd(), 131 | path.dirname(packageJsonPath), 132 | 'node_modules', 133 | externalModule.external, 134 | 'package.json' 135 | ); 136 | 137 | // eslint-disable-next-line no-nested-ternary 138 | const modulePackagePath = fse.pathExistsSync(localModulePackagePath) 139 | ? localModulePackagePath 140 | : fse.pathExistsSync(rootModulePackagePath) 141 | ? rootModulePackagePath 142 | : null; 143 | 144 | const modulePackage: Partial = modulePackagePath ? require(modulePackagePath) : {}; 145 | 146 | // Get version 147 | const moduleVersion = packageJson.dependencies?.[externalModule.external] || modulePackage.version; 148 | 149 | // add dep with version if we have it - versionless otherwise 150 | prodModules.push(moduleVersion ? `${externalModule.external}@${moduleVersion}` : externalModule.external); 151 | 152 | // Check if the module has any peer dependencies and include them too 153 | try { 154 | // find peer dependencies but remove optional ones and excluded ones 155 | const peerDependencies = modulePackage.peerDependencies as Record; 156 | const optionalPeerDependencies = Object.keys( 157 | R.pickBy((val) => val.optional, modulePackage.peerDependenciesMeta || {}) 158 | ); 159 | 160 | assert(this.buildOptions, 'buildOptions not defined'); 161 | 162 | const peerDependenciesWithoutOptionals = R.omit( 163 | [...optionalPeerDependencies, ...this.buildOptions.exclude], 164 | peerDependencies 165 | ); 166 | 167 | if (!R.isEmpty(peerDependenciesWithoutOptionals)) { 168 | this.log.debug(`Adding explicit non-optionals peers for dependency ${externalModule.external}`); 169 | const peerModules = getProdModules.call( 170 | this, 171 | R.compose( 172 | R.map(([external]) => ({ external })), 173 | R.toPairs 174 | )(peerDependenciesWithoutOptionals), 175 | packageJsonPath, 176 | rootPackageJsonPath 177 | ); 178 | 179 | Array.prototype.push.apply(prodModules, peerModules); 180 | } 181 | } catch (error) { 182 | this.log.warning(`WARNING: Could not check for peer dependencies of ${externalModule.external}`); 183 | } 184 | }, externalModules); 185 | 186 | return prodModules; 187 | } 188 | 189 | export function nodeExternalsPluginUtilsPath(): string | undefined { 190 | try { 191 | const resolvedPackage = require.resolve('esbuild-node-externals/dist/utils', { 192 | paths: [process.cwd()], 193 | }); 194 | 195 | return resolvedPackage; 196 | } catch { 197 | // No-op 198 | } 199 | } 200 | 201 | /** 202 | * We need a performant algorithm to install the packages for each single 203 | * function (in case we package individually). 204 | * (1) We fetch ALL packages needed by ALL functions in a first step 205 | * and use this as a base npm checkout. The checkout will be done to a 206 | * separate temporary directory with a package.json that contains everything. 207 | * (2) For each single compile we copy the whole node_modules to the compile 208 | * directory and create a (function) compile specific package.json and store 209 | * it in the compile directory. Now we start npm again there, and npm will just 210 | * remove the superfluous packages and optimize the remaining dependencies. 211 | * This will utilize the npm cache at its best and give us the needed results 212 | * and performance. 213 | */ 214 | // eslint-disable-next-line max-statements 215 | export async function packExternalModules(this: EsbuildServerlessPlugin) { 216 | assert(this.buildOptions, 'buildOptions not defined'); 217 | 218 | const upperPackageJson = findUp('package.json'); 219 | 220 | const { plugins } = this; 221 | 222 | if (plugins && plugins.map((plugin) => plugin.name).includes('node-externals')) { 223 | const utilsPath = nodeExternalsPluginUtilsPath(); 224 | 225 | if (utilsPath) { 226 | const { 227 | findDependencies, 228 | findPackagePaths, 229 | createAllowPredicate, 230 | }: { 231 | findDependencies: typeof FindDependenciesFn; 232 | findPackagePaths: typeof FindPackagePathsFn; 233 | createAllowPredicate: typeof CreateAllowPredicateFn; 234 | } = require(utilsPath); 235 | 236 | this.buildOptions.external = findDependencies({ 237 | packagePaths: findPackagePaths(), 238 | dependencies: true, 239 | devDependencies: false, 240 | peerDependencies: false, 241 | optionalDependencies: false, 242 | allowWorkspaces: false, 243 | allowPredicate: createAllowPredicate(this.buildOptions.nodeExternals?.allowList ?? []), 244 | }); 245 | } 246 | } 247 | 248 | const externals: string[] = 249 | Array.isArray(this.buildOptions.external) && 250 | this.buildOptions.exclude !== '*' && 251 | !this.buildOptions.exclude.includes('*') 252 | ? R.without(this.buildOptions.exclude, this.buildOptions.external) 253 | : []; 254 | 255 | if (!externals.length) { 256 | return; 257 | } 258 | 259 | // Read plugin configuration 260 | // get the root package.json by looking up until we hit a lockfile 261 | // if this is a yarn workspace, it will be the monorepo package.json 262 | const rootPackageJsonPath = path.join(findProjectRoot() || '', './package.json'); 263 | // get the local package.json by looking up until we hit a package.json file 264 | // if this is *not* a yarn workspace, it will be the same as rootPackageJsonPath 265 | const packageJsonPath = 266 | this.buildOptions.packagePath || 267 | (upperPackageJson && path.relative(process.cwd(), path.join(upperPackageJson, './package.json'))); 268 | 269 | assert(packageJsonPath, 'packageJsonPath is not defined'); 270 | 271 | // Determine and create packager 272 | const packager = await getPackager.call(this, this.buildOptions.packager, this.buildOptions.packagerOptions); 273 | 274 | // Fetch needed original package.json sections 275 | const sectionNames = packager.copyPackageSectionNames; 276 | 277 | type ScriptsRecord = Record<`script${number}`, string>; 278 | 279 | // Get scripts from packager options 280 | const packagerScripts: ScriptsRecord = 281 | typeof this.buildOptions.packagerOptions?.scripts !== 'undefined' 282 | ? (Array.isArray(this.buildOptions.packagerOptions.scripts) 283 | ? this.buildOptions.packagerOptions.scripts 284 | : [this.buildOptions.packagerOptions.scripts] 285 | ).reduce((scripts, script, index) => { 286 | // eslint-disable-next-line no-param-reassign 287 | scripts[`script${index}`] = script; 288 | 289 | return scripts; 290 | }, {}) 291 | : {}; 292 | 293 | const rootPackageJson: Record = this.serverless.utils.readFileSync(rootPackageJsonPath); 294 | 295 | const isWorkspace = !!rootPackageJson.workspaces; 296 | 297 | const packageJson: Record = isWorkspace 298 | ? (packageJsonPath && this.serverless.utils.readFileSync(packageJsonPath)) || {} 299 | : rootPackageJson; 300 | 301 | const packageSections = R.pick(sectionNames, packageJson); 302 | 303 | if (!R.isEmpty(packageSections)) { 304 | this.log.debug(`Using package.json sections ${R.join(', ', R.keys(packageSections))}`); 305 | } 306 | 307 | // Get first level dependency graph 308 | this.log.debug(`Fetch dependency graph from ${packageJson}`); 309 | 310 | // (1) Generate dependency composition 311 | const externalModules = R.map((external) => ({ external }), externals); 312 | const compositeModules: JSONObject = R.uniq( 313 | getProdModules.call(this, externalModules, packageJsonPath, rootPackageJsonPath) 314 | ); 315 | 316 | if (R.isEmpty(compositeModules)) { 317 | // The compiled code does not reference any external modules at all 318 | this.log.warning('No external modules needed'); 319 | 320 | return; 321 | } 322 | 323 | // (1.a) Install all needed modules 324 | const compositeModulePath = this.buildDirPath; 325 | 326 | assertIsString(compositeModulePath, 'compositeModulePath is not a string'); 327 | 328 | const compositePackageJson = path.join(compositeModulePath, 'package.json'); 329 | 330 | // (1.a.1) Create a package.json 331 | const compositePackage = R.mergeRight( 332 | { 333 | name: this.serverless.service.service, 334 | version: '1.0.0', 335 | description: `Packaged externals for ${this.serverless.service.service}`, 336 | private: true, 337 | scripts: packagerScripts, 338 | }, 339 | packageSections 340 | ); 341 | const relativePath = path.relative(compositeModulePath, path.dirname(packageJsonPath)); 342 | 343 | addModulesToPackageJson(compositeModules, compositePackage, relativePath); 344 | this.serverless.utils.writeFileSync(compositePackageJson, JSON.stringify(compositePackage, null, 2)); 345 | 346 | // (1.a.2) Copy package-lock.json if it exists, to prevent unwanted upgrades 347 | const packageLockPath = path.join(process.cwd(), path.dirname(packageJsonPath), packager.lockfileName); 348 | const exists = await fse.pathExists(packageLockPath); 349 | 350 | if (exists) { 351 | this.log.verbose('Package lock found - Using locked versions'); 352 | try { 353 | let packageLockFile = this.serverless.utils.readFileSync(packageLockPath); 354 | 355 | packageLockFile = packager.rebaseLockfile(relativePath, packageLockFile); 356 | if (R.is(Object)(packageLockFile)) { 357 | packageLockFile = JSON.stringify(packageLockFile, null, 2); 358 | } 359 | 360 | this.serverless.utils.writeFileSync( 361 | path.join(compositeModulePath, packager.lockfileName), 362 | packageLockFile as string 363 | ); 364 | } catch (error) { 365 | this.log.warning(`Warning: Could not read lock file${error instanceof Error ? `: ${error.message}` : ''}`); 366 | } 367 | } 368 | 369 | // GOOGLE: Copy modules only if not google-cloud-functions 370 | // GCF Auto installs the package json 371 | if (R.path(['service', 'provider', 'name'], this.serverless) === 'google') { 372 | return; 373 | } 374 | 375 | const start = Date.now(); 376 | 377 | this.log.verbose(`Packing external modules: ${compositeModules.join(', ')}`); 378 | const { installExtraArgs } = this.buildOptions; 379 | 380 | await packager.install(compositeModulePath, installExtraArgs, exists); 381 | this.log.debug(`Package took [${Date.now() - start} ms]`); 382 | 383 | // Prune extraneous packages - removes not needed ones 384 | const startPrune = Date.now(); 385 | 386 | await packager.prune(compositeModulePath); 387 | 388 | this.log.debug(`Prune: ${compositeModulePath} [${Date.now() - startPrune} ms]`); 389 | 390 | assertIsString(this.buildDirPath, 'buildDirPath is not a string'); 391 | 392 | // Run packager scripts 393 | if (Object.keys(packagerScripts).length > 0) { 394 | const startScripts = Date.now(); 395 | 396 | await packager.runScripts(this.buildDirPath, Object.keys(packagerScripts)); 397 | 398 | this.log.debug( 399 | `Packager scripts took [${Date.now() - startScripts} ms].\nExecuted scripts: ${Object.values(packagerScripts).map( 400 | (script) => `\n ${script}` 401 | )}` 402 | ); 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/pack.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import path from 'path'; 3 | 4 | import pMap from 'p-map'; 5 | import fs from 'fs-extra'; 6 | import globby from 'globby'; 7 | import { intersection, isEmpty, lensProp, map, over, pipe, reject, replace, test, without } from 'ramda'; 8 | import semver from 'semver'; 9 | import type Serverless from 'serverless'; 10 | 11 | import { ONLY_PREFIX, SERVERLESS_FOLDER } from './constants'; 12 | import { assertIsString, doSharePath, flatDep, getDepsFromBundle, isESM, stripEntryResolveExtensions } from './helper'; 13 | import { getPackager } from './packagers'; 14 | import { humanSize, trimExtension, zip } from './utils'; 15 | 16 | import type EsbuildServerlessPlugin from './index'; 17 | import type { EsbuildFunctionDefinitionHandler, FunctionBuildResult, FunctionReference, IFiles } from './types'; 18 | 19 | function setFunctionArtifactPath( 20 | this: EsbuildServerlessPlugin, 21 | func: Serverless.FunctionDefinitionHandler, 22 | artifactPath: string 23 | ) { 24 | const version = this.serverless.getVersion(); 25 | 26 | // Serverless changed the artifact path location in version 1.18 27 | if (semver.lt(version, '1.18.0')) { 28 | // eslint-disable-next-line no-param-reassign 29 | (func as any).artifact = artifactPath; 30 | // eslint-disable-next-line no-param-reassign, prefer-object-spread 31 | func.package = Object.assign({}, func.package, { disable: true }); 32 | this.log.verbose(`${func.name} is packaged by the esbuild plugin. Ignore messages from SLS.`); 33 | } else { 34 | // eslint-disable-next-line no-param-reassign 35 | func.package = { 36 | artifact: artifactPath, 37 | }; 38 | } 39 | } 40 | 41 | const excludedFilesDefault = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'package.json']; 42 | 43 | export const filterFilesForZipPackage = ({ 44 | files, 45 | functionAlias, 46 | includedFiles, 47 | excludedFiles, 48 | hasExternals, 49 | isGoogleProvider, 50 | depWhiteList, 51 | }: { 52 | files: IFiles; 53 | functionAlias: string; 54 | includedFiles: string[]; 55 | excludedFiles: string[]; 56 | hasExternals: boolean; 57 | isGoogleProvider: boolean; 58 | depWhiteList: string[]; 59 | }) => { 60 | return files.filter(({ localPath }) => { 61 | // if file is present in patterns it must be included 62 | if (includedFiles.find((file) => file === localPath)) { 63 | return true; 64 | } 65 | 66 | // exclude non individual files based on file path (and things that look derived, e.g. foo.js => foo.js.map) 67 | if (excludedFiles.find((file) => localPath.startsWith(`${file}.`))) { 68 | return false; 69 | } 70 | 71 | // exclude files that belong to individual functions 72 | if (localPath.startsWith(ONLY_PREFIX) && !localPath.startsWith(`${ONLY_PREFIX}${functionAlias}/`)) return false; 73 | 74 | // exclude non whitelisted dependencies 75 | if (localPath.startsWith('node_modules')) { 76 | // if no externals is set or if the provider is google, we do not need any files from node_modules 77 | if (!hasExternals || isGoogleProvider) return false; 78 | if ( 79 | // this is needed for dependencies that maps to a path (like scoped ones) 80 | !depWhiteList.find((dep) => doSharePath(localPath, `node_modules/${dep}`)) 81 | ) 82 | return false; 83 | } 84 | 85 | return true; 86 | }); 87 | }; 88 | 89 | // eslint-disable-next-line max-statements 90 | export async function pack(this: EsbuildServerlessPlugin) { 91 | // GOOGLE Provider requires a package.json and NO node_modules 92 | 93 | const providerName = this.serverless?.service?.provider?.name; 94 | const isGoogleProvider = this.serverless?.service?.provider?.name === 'google'; 95 | const isScalewayProvider = this.serverless?.service?.provider?.name === 'scaleway'; // Scaleway can not have package: individually 96 | const excludedFiles = isGoogleProvider ? [] : excludedFilesDefault; 97 | 98 | // Google and Scaleway providers cannot use individual packaging for now - this could be built in a future release 99 | const isPackageIndividuallyNotSupported = isGoogleProvider || isScalewayProvider || false; 100 | if (isPackageIndividuallyNotSupported && this.serverless?.service?.package?.individually) { 101 | throw new Error(`Packaging failed: cannot package function individually when using ${providerName} provider`); 102 | } 103 | 104 | const { buildDirPath, workDirPath } = this; 105 | 106 | assertIsString(buildDirPath, 'buildDirPath is not a string'); 107 | assertIsString(workDirPath, 'workDirPath is not a string'); 108 | 109 | // get a list of all path in build 110 | const files: IFiles = globby 111 | .sync('**', { 112 | cwd: buildDirPath, 113 | dot: true, 114 | onlyFiles: true, 115 | }) 116 | .filter((file) => !excludedFiles.includes(file)) 117 | .map((localPath) => ({ localPath, rootPath: path.join(buildDirPath, localPath) })) 118 | .map((file) => { 119 | if (this.buildOptions?.resolveExtensions && this.buildOptions.resolveExtensions.length > 0) { 120 | if (this.buildOptions.stripEntryResolveExtensions) { 121 | return stripEntryResolveExtensions(file, this.buildOptions.resolveExtensions); 122 | } 123 | } 124 | 125 | return file; 126 | }); 127 | 128 | if (isEmpty(files)) { 129 | this.log.verbose('Packaging: No files found. Skipping esbuild.'); 130 | return; 131 | } 132 | 133 | // 1) If individually is not set, just zip the all build dir and return 134 | if (!this.serverless?.service?.package?.individually) { 135 | const zipName = `${this.serverless.service.service}.zip`; 136 | const artifactPath = path.join(workDirPath, SERVERLESS_FOLDER, zipName); 137 | 138 | // remove prefixes from individual extra files 139 | const filesPathList = pipe( 140 | reject(test(/^__only_[^/]+$/)) as (x: IFiles) => IFiles, 141 | map(over(lensProp('localPath'), replace(/^__only_[^/]+\//, ''))) 142 | )(files); 143 | 144 | const startZip = Date.now(); 145 | 146 | await zip(artifactPath, filesPathList, this.buildOptions?.nativeZip); 147 | const { size } = fs.statSync(artifactPath); 148 | 149 | this.log.verbose( 150 | `Zip service ${this.serverless.service.service} - ${humanSize(size)} [${Date.now() - startZip} ms]` 151 | ); 152 | // defined present zip as output artifact 153 | this.serverless.service.package.artifact = artifactPath; 154 | 155 | return; 156 | } 157 | 158 | assertIsString(this.buildOptions?.packager, 'packager is not a string'); 159 | 160 | // 2) If individually is set, we'll optimize files and zip per-function 161 | const packager = await getPackager.call(this, this.buildOptions.packager, this.buildOptions.packagerOptions); 162 | 163 | // get a list of every function bundle 164 | const { buildResults } = this; 165 | 166 | assert(buildResults, 'buildResults is not an array'); 167 | 168 | const bundlePathList = buildResults.map((results) => results.bundlePath); 169 | 170 | let externals: string[] = []; 171 | 172 | // get the list of externals to include only if exclude is not set to * 173 | if (this.buildOptions.exclude !== '*' && !this.buildOptions.exclude.includes('*')) { 174 | externals = without(this.buildOptions.exclude, this.buildOptions.external ?? []); 175 | } 176 | 177 | const hasExternals = !!externals?.length; 178 | 179 | const { buildOptions } = this; 180 | 181 | // get a tree of all production dependencies 182 | const packagerDependenciesList = hasExternals ? await packager.getProdDependencies(buildDirPath) : {}; 183 | 184 | const packageFiles = await globby(this.serverless.service.package.patterns); 185 | 186 | const zipMapper = async (buildResult: FunctionBuildResult) => { 187 | const { func, functionAlias, bundlePath } = buildResult; 188 | 189 | const bundleExcludedFiles = bundlePathList.filter((item) => !bundlePath.startsWith(item)).map(trimExtension); 190 | 191 | const functionPackagePatterns = func.package?.patterns || []; 192 | 193 | const functionExclusionPatterns = functionPackagePatterns 194 | .filter((pattern) => pattern.charAt(0) === '!') 195 | .map((pattern) => pattern.slice(1)); 196 | 197 | const functionFiles = await globby(functionPackagePatterns, { cwd: buildDirPath }); 198 | const functionExcludedFiles = (await globby(functionExclusionPatterns, { cwd: buildDirPath })).map(trimExtension); 199 | 200 | const includedFiles = [...packageFiles, ...functionFiles]; 201 | const excludedPackageFiles = [...bundleExcludedFiles, ...functionExcludedFiles]; 202 | 203 | // allowed external dependencies in the final zip 204 | let depWhiteList: string[] = []; 205 | 206 | if (hasExternals && packagerDependenciesList.dependencies) { 207 | const bundleDeps = getDepsFromBundle(path.join(buildDirPath, bundlePath), isESM(buildOptions)); 208 | const bundleExternals = intersection(bundleDeps, externals); 209 | 210 | depWhiteList = flatDep(packagerDependenciesList.dependencies, bundleExternals); 211 | } 212 | 213 | const zipName = `${functionAlias}.zip`; 214 | const artifactPath = path.join(workDirPath, SERVERLESS_FOLDER, zipName); 215 | 216 | // filter files 217 | const filesPathList = filterFilesForZipPackage({ 218 | files, 219 | functionAlias, 220 | includedFiles, 221 | hasExternals, 222 | isGoogleProvider, 223 | depWhiteList, 224 | excludedFiles: excludedPackageFiles, 225 | }) 226 | // remove prefix from individual function extra files 227 | .map(({ localPath, ...rest }) => ({ 228 | localPath: localPath.replace(`${ONLY_PREFIX}${functionAlias}/`, ''), 229 | ...rest, 230 | })); 231 | 232 | const startZip = Date.now(); 233 | await zip(artifactPath, filesPathList, buildOptions.nativeZip); 234 | const { size } = fs.statSync(artifactPath); 235 | this.log.verbose(`Function zipped: ${functionAlias} - ${humanSize(size)} [${Date.now() - startZip} ms]`); 236 | 237 | // defined present zip as output artifact 238 | setFunctionArtifactPath.call(this, func, path.relative(this.serviceDirPath, artifactPath)); 239 | }; 240 | 241 | this.log.verbose(`Zipping with concurrency: ${buildOptions.zipConcurrency}`); 242 | await pMap(buildResults, zipMapper, { concurrency: buildOptions.zipConcurrency }); 243 | this.log.verbose('All functions zipped.'); 244 | } 245 | 246 | export async function copyPreBuiltResources(this: EsbuildServerlessPlugin) { 247 | this.log.verbose('Copying Prebuilt resources'); 248 | 249 | const { workDirPath, packageOutputPath } = this; 250 | 251 | assertIsString(workDirPath, 'workDirPath is not a string'); 252 | assertIsString(packageOutputPath, 'packageOutputPath is not a string'); 253 | 254 | // 1) If individually is not set, just zip the all build dir and return 255 | if (!this.serverless?.service?.package?.individually) { 256 | const zipName = `${this.serverless.service.service}.zip`; 257 | await fs.copy(path.join(packageOutputPath, zipName), path.join(workDirPath, SERVERLESS_FOLDER, zipName)); 258 | // defined present zip as output artifact 259 | this.serverless.service.package.artifact = path.join(workDirPath, SERVERLESS_FOLDER, zipName); 260 | 261 | return; 262 | } 263 | 264 | // get a list of every function bundle 265 | const buildResults = Object.entries(this.functions) 266 | .filter(([functionAlias, func]) => func && functionAlias) 267 | .map(([functionAlias, func]) => ({ func, functionAlias })) as FunctionReference[]; 268 | 269 | assert(buildResults, 'buildResults is not an array'); 270 | const zipMapper = async (buildResult: FunctionReference) => { 271 | const { func, functionAlias } = buildResult; 272 | 273 | if ((func as EsbuildFunctionDefinitionHandler).skipEsbuild) { 274 | const zipName = `${functionAlias}.zip`; 275 | const artifactPath = path.join(workDirPath, SERVERLESS_FOLDER, zipName); 276 | 277 | // defined present zip as output artifact 278 | await fs.copy(path.join(packageOutputPath, zipName), artifactPath); 279 | setFunctionArtifactPath.call(this, func, path.relative(this.serviceDirPath, artifactPath)); 280 | } 281 | }; 282 | 283 | await pMap(buildResults, zipMapper, {}); 284 | this.log.verbose('All functions copied.'); 285 | } 286 | -------------------------------------------------------------------------------- /src/packagers/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory for supported packagers. 3 | * 4 | * All packagers must extend the Packager class. 5 | * 6 | * @see Packager 7 | */ 8 | import { memoizeWith } from 'ramda'; 9 | 10 | import { isPackagerId } from '../type-predicate'; 11 | 12 | import type EsbuildServerlessPlugin from '../index'; 13 | import type { PackagerId, PackagerOptions } from '../types'; 14 | import type { Packager } from './packager'; 15 | 16 | const packagerFactories: Record Promise> = { 17 | async npm() { 18 | const { NPM } = await import('./npm'); 19 | 20 | return new NPM(); 21 | }, 22 | async pnpm() { 23 | const { Pnpm } = await import('./pnpm'); 24 | 25 | return new Pnpm(); 26 | }, 27 | async yarn(packagerOptions) { 28 | const { Yarn } = await import('./yarn'); 29 | 30 | return new Yarn(packagerOptions); 31 | }, 32 | }; 33 | 34 | /** 35 | * Asynchronously create a Packager instance and memoize it. 36 | * 37 | * @this EsbuildServerlessPlugin - Active plugin instance 38 | * @param {string} packagerId - Well known packager id 39 | * @returns {Promise} - The selected Packager 40 | */ 41 | export const getPackager = memoizeWith( 42 | (packagerId) => packagerId, 43 | async function ( 44 | this: EsbuildServerlessPlugin, 45 | packagerId: PackagerId, 46 | packagerOptions: PackagerOptions 47 | ): Promise { 48 | this.log.debug(`Trying to create packager: ${packagerId}`); 49 | 50 | if (!isPackagerId(packagerId)) { 51 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 52 | // @ts-ignore Serverless typings (as of v3.0.2) are incorrect 53 | throw new this.serverless.classes.Error(`Could not find packager '${packagerId}'`); 54 | } 55 | 56 | const packager = await packagerFactories[packagerId](packagerOptions); 57 | 58 | this.log.debug(`Packager created: ${packagerId}`); 59 | 60 | return packager; 61 | } 62 | ); 63 | -------------------------------------------------------------------------------- /src/packagers/npm.ts: -------------------------------------------------------------------------------- 1 | import { Predicate } from 'effect'; 2 | import { any, isEmpty, replace, split, startsWith, takeWhile } from 'ramda'; 3 | import * as path from 'path'; 4 | 5 | import type { DependenciesResult, DependencyMap, JSONObject } from '../types'; 6 | import { SpawnError, spawnProcess } from '../utils'; 7 | import type { Packager } from './packager'; 8 | 9 | type NpmV7Map = Record; 10 | 11 | export interface NpmV7Tree { 12 | version: string; 13 | resolved: string; 14 | name: string; 15 | integrity: string; 16 | _id: string; 17 | extraneous: boolean; 18 | path: string; 19 | _dependencies: Record; 20 | devDependencies: Record; 21 | peerDependencies: Record; 22 | dependencies?: NpmV7Map; 23 | } 24 | 25 | export interface NpmV7Deps { 26 | version: string; 27 | name: string; 28 | description: string; 29 | private: boolean; 30 | scripts: Record; 31 | _id: string; 32 | extraneous: boolean; 33 | path: string; 34 | _dependencies: Record; 35 | devDependencies: Record; 36 | peerDependencies: Record; 37 | dependencies: NpmV7Map; 38 | } 39 | 40 | export type NpmV6Map = Record; 41 | 42 | export interface NpmV6Tree { 43 | _args: string[][] | string; 44 | _from: string; 45 | _id: string; 46 | _integrity: string; 47 | _location: string; 48 | _phantomChildren: Record | string; 49 | _requested: Record; 50 | _requiredBy: string[] | string; 51 | _resolved: string; 52 | _spec: string; 53 | _where: string; 54 | author: string; 55 | license: string; 56 | main: string; 57 | name: string; 58 | scripts: Record | string; 59 | version: string; 60 | readme: string; 61 | dependencies: NpmV6Map; 62 | devDependencies: Record | string; 63 | optionalDependencies: Record | string; 64 | _dependencies: Record | string; 65 | path: string; 66 | error: string | Error; 67 | extraneous: boolean; 68 | _deduped?: string; 69 | } 70 | 71 | export interface NpmV6Deps { 72 | name: string; 73 | version: string; 74 | description: string; 75 | private: boolean; 76 | scripts: Record; 77 | dependencies?: NpmV6Map; 78 | readme?: string; 79 | _id: string; 80 | _shrinkwrap: Record; 81 | devDependencies: Record; 82 | optionalDependencies: Record; 83 | _dependencies: Record; 84 | path: string; 85 | error: string | Error; 86 | extraneous: boolean; 87 | } 88 | 89 | /** 90 | * NPM packager. 91 | */ 92 | export class NPM implements Packager { 93 | get lockfileName() { 94 | return 'package-lock.json'; 95 | } 96 | 97 | get copyPackageSectionNames() { 98 | return []; 99 | } 100 | 101 | get mustCopyModules() { 102 | return true; 103 | } 104 | 105 | private async getNpmMajorVersion(cwd: string): Promise { 106 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 107 | const args = ['--version']; 108 | 109 | const processOutput = await spawnProcess(command, args, { cwd }); 110 | const version = processOutput.stdout.trim(); 111 | 112 | const [major] = version.split('.'); 113 | 114 | if (major) { 115 | return parseInt(major, 10); 116 | } 117 | 118 | throw new Error('Unable to get major npm version'); 119 | } 120 | 121 | async getProdDependencies(cwd: string, depth?: number): Promise { 122 | const npmMajorVersion = await this.getNpmMajorVersion(cwd); 123 | const prodFlag = npmMajorVersion >= 7 ? '--omit=dev' : '-prod'; 124 | const noDepthFlag = npmMajorVersion >= 7 ? '-all' : null; 125 | 126 | // Get first level dependency graph 127 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 128 | const args = [ 129 | 'ls', 130 | '-json', 131 | prodFlag, // Only prod dependencies 132 | '-long', 133 | depth ? `-depth=${depth}` : noDepthFlag, 134 | ].filter(Predicate.isString); 135 | 136 | const ignoredNpmErrors: Array<{ 137 | npmError: string; 138 | log: boolean; 139 | }> = [ 140 | { npmError: 'extraneous', log: false }, 141 | { npmError: 'missing', log: false }, 142 | { npmError: 'peer dep missing', log: true }, 143 | { npmError: 'code ELSPROBLEMS', log: false }, 144 | ]; 145 | 146 | let parsedDeps: NpmV6Deps | NpmV7Deps; 147 | 148 | try { 149 | const processOutput = await spawnProcess(command, args, { cwd }); 150 | 151 | parsedDeps = JSON.parse(processOutput.stdout) as NpmV6Deps | NpmV7Deps; 152 | } catch (err) { 153 | if (err instanceof SpawnError) { 154 | // Only exit with an error if we have critical npm errors for 2nd level inside 155 | // Split the stderr by \n character to get the npm ERR! plaintext lines, ignore additional JSON blob (emitted by npm >=7) 156 | // see https://github.com/serverless-heaven/serverless-webpack/pull/782 and https://github.com/floydspace/serverless-esbuild/issues/288 157 | const lines = split('\n', err.stderr); 158 | const npmErrors = takeWhile((line) => line !== '{', lines); 159 | 160 | const hasThrowableErrors = npmErrors.every( 161 | (error) => 162 | !isEmpty(error) && 163 | !any((ignoredError) => startsWith(`npm ERR! ${ignoredError.npmError}`, error), ignoredNpmErrors) 164 | ); 165 | 166 | if (!hasThrowableErrors && !isEmpty(err.stdout)) { 167 | return { stdout: err.stdout }; 168 | } 169 | } 170 | 171 | throw err; 172 | } 173 | 174 | const basePath = parsedDeps.path; 175 | 176 | const convertTrees = ( 177 | currentTree: NpmV6Map | NpmV7Map, 178 | rootDeps: DependencyMap, 179 | currentDeps: DependencyMap = rootDeps 180 | ): DependencyMap => { 181 | return Object.entries(currentTree).reduce((deps, [name, tree]) => { 182 | if (tree.path === path.join(basePath, 'node_modules', name)) { 183 | // Module path is in the root folder 184 | 185 | // If this isn't the root of the tree 186 | if (rootDeps !== deps) { 187 | // Set it as resolved 188 | // eslint-disable-next-line no-param-reassign 189 | deps[name] ??= { 190 | version: tree.version, 191 | isRootDep: true, 192 | }; 193 | } 194 | if (tree._deduped || (!isEmpty(tree._dependencies) && !tree.dependencies)) { 195 | // Edge case - When it is de-duped this record will not contain the dependency tree. 196 | // _deduped is for v6 (Object.keys(tree._dependencies).length && !tree.dependencies) for v7 197 | // We can just ignore storing this at the root because it does not contain the tree we are after 198 | // "samchungy-dep-b": { 199 | // "version": "3.0.0", 200 | // "name": "samchungy-dep-b", 201 | // "resolved": "https://registry.npmjs.org/samchungy-dep-b/-/samchungy-dep-b-3.0.0.tgz", 202 | // "integrity": "sha512-fy6RAnofLSnLHgOUmgsFz0ZFnJcJeNHT+qUfHJ7daIFlBaciRDR6v5sdWm7mAM2EzQ1KFf2hmKJVFZgthVeCAw==", 203 | // "_id": "samchungy-dep-b@3.0.0", 204 | // "extraneous": false, 205 | // "path": "/Users/schung/me/serverless-esbuild/examples/individually/node_modules/samchungy-dep-b", 206 | // "_dependencies": { 207 | // "samchungy-dep-c": "^1.0.0", 208 | // "samchungy-dep-d": "^1.0.0" 209 | // }, 210 | // "devDependencies": {}, 211 | // "peerDependencies": {} 212 | // } 213 | } else { 214 | // This is a root node_modules dependency 215 | // eslint-disable-next-line no-param-reassign 216 | rootDeps[name] ??= { 217 | version: tree.version, 218 | ...(tree.dependencies && 219 | !isEmpty(tree.dependencies) && { 220 | dependencies: convertTrees(tree.dependencies, rootDeps, {}), 221 | }), 222 | }; 223 | } 224 | 225 | return deps; 226 | } 227 | 228 | // Module is only installed within the node_modules of this dep. Iterate through it's dep tree 229 | // eslint-disable-next-line no-param-reassign 230 | deps[name] ??= { 231 | version: tree.version, 232 | ...(tree.dependencies && 233 | !isEmpty(tree.dependencies) && { 234 | dependencies: convertTrees(tree.dependencies, rootDeps, {}), 235 | }), 236 | }; 237 | 238 | return deps; 239 | }, currentDeps); 240 | }; 241 | 242 | return { 243 | ...(parsedDeps.dependencies && 244 | !isEmpty(parsedDeps.dependencies) && { 245 | dependencies: convertTrees(parsedDeps.dependencies, {}), 246 | }), 247 | }; 248 | } 249 | 250 | /** 251 | * We should not be modifying 'package-lock.json' 252 | * because this file should be treated as internal to npm. 253 | * 254 | * Rebase package-lock is a temporary workaround and must be 255 | * removed as soon as https://github.com/npm/npm/issues/19183 gets fixed. 256 | */ 257 | rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject) { 258 | if (lockfile.version) { 259 | // eslint-disable-next-line no-param-reassign 260 | lockfile.version = this._rebaseFileReferences(pathToPackageRoot, lockfile.version); 261 | } 262 | 263 | if (lockfile.dependencies) { 264 | for (const lockedDependency in lockfile.dependencies) { 265 | this.rebaseLockfile(pathToPackageRoot, lockedDependency); 266 | } 267 | } 268 | 269 | return lockfile; 270 | } 271 | 272 | async install(cwd: string, extraArgs: Array) { 273 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 274 | 275 | const args = ['install', ...extraArgs]; 276 | 277 | await spawnProcess(command, args, { cwd }); 278 | } 279 | 280 | async prune(cwd: string) { 281 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 282 | const args = ['prune']; 283 | 284 | await spawnProcess(command, args, { cwd }); 285 | } 286 | 287 | async runScripts(cwd: string, scriptNames: string[]) { 288 | const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; 289 | 290 | await Promise.all( 291 | scriptNames.map((scriptName) => { 292 | const args = ['run', scriptName]; 293 | 294 | return spawnProcess(command, args, { cwd }); 295 | }) 296 | ); 297 | } 298 | 299 | private _rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { 300 | if (/^file:[^/]{2}/.test(moduleVersion)) { 301 | const filePath = replace(/^file:/, '', moduleVersion); 302 | 303 | return replace(/\\/g, '/', `file:${pathToPackageRoot}/${filePath}`); 304 | } 305 | 306 | return moduleVersion; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/packagers/packager.ts: -------------------------------------------------------------------------------- 1 | import type { DependenciesResult, JSONObject } from '../types'; 2 | 3 | export interface Packager { 4 | lockfileName: string; 5 | copyPackageSectionNames: Array; 6 | mustCopyModules: boolean; 7 | getProdDependencies(cwd: string, depth?: number): Promise; 8 | rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject): JSONObject; 9 | install(cwd: string, extraArgs: Array, useLockfile?: boolean): Promise; 10 | prune(cwd: string): Promise; 11 | runScripts(cwd: string, scriptNames: string[]): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/packagers/pnpm.ts: -------------------------------------------------------------------------------- 1 | import { Predicate } from 'effect'; 2 | import { isEmpty, reduce, replace, split, startsWith } from 'ramda'; 3 | 4 | import type { JSONObject } from '../types'; 5 | import { SpawnError, spawnProcess } from '../utils'; 6 | import type { Packager } from './packager'; 7 | 8 | /** 9 | * pnpm packager. 10 | */ 11 | export class Pnpm implements Packager { 12 | get lockfileName() { 13 | return 'pnpm-lock.yaml'; 14 | } 15 | 16 | get copyPackageSectionNames() { 17 | return []; 18 | } 19 | 20 | get mustCopyModules() { 21 | return false; 22 | } 23 | 24 | async getProdDependencies(cwd: string, depth?: number) { 25 | // Get first level dependency graph 26 | const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; 27 | const args = [ 28 | 'ls', 29 | '--prod', // Only prod dependencies 30 | '--json', 31 | depth ? `--depth=${depth}` : null, 32 | ].filter(Predicate.isString); 33 | 34 | // If we need to ignore some errors add them here 35 | const ignoredPnpmErrors: Array<{ 36 | npmError: string; 37 | log: boolean; 38 | }> = []; 39 | 40 | try { 41 | const processOutput = await spawnProcess(command, args, { cwd }); 42 | const depJson = processOutput.stdout; 43 | 44 | return JSON.parse(depJson)[0]; 45 | } catch (err) { 46 | if (err instanceof SpawnError) { 47 | // Only exit with an error if we have critical npm errors for 2nd level inside 48 | const errors = split('\n', err.stderr); 49 | const failed = reduce( 50 | (acc, error) => { 51 | if (acc) { 52 | return true; 53 | } 54 | 55 | return ( 56 | !isEmpty(error) && 57 | !ignoredPnpmErrors.some((ignoredError) => startsWith(`npm ERR! ${ignoredError.npmError}`, error)) 58 | ); 59 | }, 60 | false, 61 | errors 62 | ); 63 | 64 | if (!failed && !isEmpty(err.stdout)) { 65 | return { stdout: err.stdout }; 66 | } 67 | } 68 | 69 | throw err; 70 | } 71 | } 72 | 73 | /** 74 | * We should not be modifying 'pnpm-lock.yaml' 75 | * because this file should be treated as internal to pnpm. 76 | */ 77 | rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject) { 78 | if (lockfile.version) { 79 | // eslint-disable-next-line no-param-reassign 80 | lockfile.version = this._rebaseFileReferences(pathToPackageRoot, lockfile.version); 81 | } 82 | 83 | if (lockfile.dependencies) { 84 | for (const lockedDependency in lockfile.dependencies) { 85 | this.rebaseLockfile(pathToPackageRoot, lockedDependency); 86 | } 87 | } 88 | 89 | return lockfile; 90 | } 91 | 92 | async install(cwd: string, extraArgs: Array) { 93 | const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; 94 | 95 | const args = ['install', '--no-frozen-lockfile', ...extraArgs]; 96 | 97 | await spawnProcess(command, args, { cwd }); 98 | } 99 | 100 | async prune(cwd: string) { 101 | const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; 102 | const args = ['prune']; 103 | 104 | await spawnProcess(command, args, { cwd }); 105 | } 106 | 107 | async runScripts(cwd: string, scriptNames: string[]) { 108 | const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; 109 | 110 | await Promise.all( 111 | scriptNames.map((scriptName) => { 112 | const args = ['run', scriptName]; 113 | 114 | return spawnProcess(command, args, { cwd }); 115 | }) 116 | ); 117 | } 118 | 119 | private _rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { 120 | if (/^file:[^/]{2}/.test(moduleVersion)) { 121 | const filePath = replace(/^file:/, '', moduleVersion); 122 | 123 | return replace(/\\/g, '/', `file:${pathToPackageRoot}/${filePath}`); 124 | } 125 | 126 | return moduleVersion; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/packagers/yarn.ts: -------------------------------------------------------------------------------- 1 | import { Predicate } from 'effect'; 2 | import { any, isEmpty, reduce, replace, split, startsWith } from 'ramda'; 3 | import { satisfies } from 'semver'; 4 | 5 | import type { DependenciesResult, DependencyMap, PackagerOptions } from '../types'; 6 | import { SpawnError, spawnProcess } from '../utils'; 7 | import type { Packager } from './packager'; 8 | 9 | interface YarnTree { 10 | name: string; 11 | color: 'bold' | 'dim' | null; 12 | children?: YarnTree[]; 13 | hint?: null; 14 | depth?: number; 15 | shadow?: boolean; 16 | } 17 | export interface YarnDeps { 18 | type: 'tree'; 19 | data: { 20 | type: 'list'; 21 | trees: YarnTree[]; 22 | }; 23 | } 24 | 25 | const getNameAndVersion = (name: string): { name: string; version: string } => { 26 | const atIndex = name.lastIndexOf('@'); 27 | 28 | return { 29 | name: name.slice(0, atIndex), 30 | version: name.slice(atIndex + 1), 31 | }; 32 | }; 33 | 34 | /** 35 | * Yarn packager. 36 | * 37 | * Yarn specific packagerOptions (default): 38 | * flat (false) - Use --flat with install 39 | * ignoreScripts (false) - Do not execute scripts during install 40 | */ 41 | export class Yarn implements Packager { 42 | private packagerOptions: PackagerOptions; 43 | 44 | constructor(packagerOptions: PackagerOptions) { 45 | this.packagerOptions = packagerOptions; 46 | } 47 | 48 | get lockfileName() { 49 | return 'yarn.lock'; 50 | } 51 | 52 | get copyPackageSectionNames() { 53 | return ['resolutions']; 54 | } 55 | 56 | get mustCopyModules() { 57 | return false; 58 | } 59 | 60 | async getVersion(cwd: string) { 61 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 62 | const args = ['-v']; 63 | 64 | const output = await spawnProcess(command, args, { cwd }); 65 | 66 | return { 67 | version: output.stdout, 68 | isBerry: parseInt(output.stdout.charAt(0), 10) > 1, 69 | }; 70 | } 71 | 72 | async getProdDependencies(cwd: string, depth?: number): Promise { 73 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 74 | const args = ['list', depth ? `--depth=${depth}` : null, '--json', '--production'].filter(Predicate.isString); 75 | 76 | // If we need to ignore some errors add them here 77 | const ignoredYarnErrors: Array<{ 78 | npmError: string; 79 | log: boolean; 80 | }> = []; 81 | 82 | let parsedDeps: YarnDeps; 83 | 84 | try { 85 | const processOutput = await spawnProcess(command, args, { cwd }); 86 | 87 | parsedDeps = JSON.parse(processOutput.stdout) as YarnDeps; 88 | } catch (err) { 89 | if (err instanceof SpawnError) { 90 | // Only exit with an error if we have critical npm errors for 2nd level inside 91 | const errors = split('\n', err.stderr); 92 | const failed = reduce( 93 | (acc, error) => { 94 | if (acc) { 95 | return true; 96 | } 97 | 98 | return ( 99 | !isEmpty(error) && 100 | !any((ignoredError) => startsWith(`npm ERR! ${ignoredError.npmError}`, error), ignoredYarnErrors) 101 | ); 102 | }, 103 | false, 104 | errors 105 | ); 106 | 107 | if (!failed && !isEmpty(err.stdout)) { 108 | return { stdout: err.stdout }; 109 | } 110 | } 111 | 112 | throw err; 113 | } 114 | 115 | const rootTree = parsedDeps.data.trees; 116 | 117 | // Produces a version map for the modules present in our root node_modules folder 118 | const rootDependencies = rootTree.reduce((deps, tree) => { 119 | const { name, version } = getNameAndVersion(tree.name); 120 | 121 | // eslint-disable-next-line no-param-reassign 122 | deps[name] ??= { 123 | version, 124 | }; 125 | 126 | return deps; 127 | }, {}); 128 | 129 | const convertTrees = (trees: YarnTree[]): DependencyMap => { 130 | return trees.reduce((deps, tree) => { 131 | const { name, version } = getNameAndVersion(tree.name); 132 | 133 | const dependency = rootDependencies[name]; 134 | 135 | if (tree.shadow) { 136 | // Package is resolved somewhere else 137 | if (dependency && satisfies(dependency.version, version)) { 138 | // Package is at root level 139 | // { 140 | // "name": "samchungy-dep-a@1.0.0", <- MATCH 141 | // "children": [], 142 | // "hint": null, 143 | // "color": null, 144 | // "depth": 0 145 | // }, 146 | // { 147 | // "name": "samchungy-a@2.0.0", 148 | // "children": [ 149 | // { 150 | // "name": "samchungy-dep-a@1.0.0", <- THIS 151 | // "color": "dim", 152 | // "shadow": true 153 | // } 154 | // ], 155 | // "hint": null, 156 | // "color": "bold", 157 | // "depth": 0 158 | // } 159 | // eslint-disable-next-line no-param-reassign 160 | deps[name] ??= { 161 | version, 162 | isRootDep: true, 163 | }; 164 | } else { 165 | // Package info is in anther child so we can just ignore 166 | // samchungy-dep-a@1.0.0 is in the root (see above example) 167 | // { 168 | // "name": "samchungy-b@2.0.0", 169 | // "children": [ 170 | // { 171 | // "name": "samchungy-dep-a@2.0.0", <- THIS 172 | // "color": "dim", 173 | // "shadow": true 174 | // }, 175 | // { 176 | // "name": "samchungy-dep-a@2.0.0", 177 | // "children": [], 178 | // "hint": null, 179 | // "color": "bold", 180 | // "depth": 0 181 | // } 182 | // ], 183 | // "hint": null, 184 | // "color": "bold", 185 | // "depth": 0 186 | // } 187 | } 188 | 189 | return deps; 190 | } 191 | 192 | // Package is not defined, store it and get the children 193 | // { 194 | // "name": "samchungy-dep-a@2.0.0", 195 | // "children": [], 196 | // "hint": null, 197 | // "color": "bold", 198 | // "depth": 0 199 | // } 200 | // eslint-disable-next-line no-param-reassign 201 | deps[name] ??= { 202 | version, 203 | ...(tree?.children?.length && { dependencies: convertTrees(tree.children) }), 204 | }; 205 | 206 | return deps; 207 | }, {}); 208 | }; 209 | 210 | return { 211 | dependencies: convertTrees(rootTree), 212 | }; 213 | } 214 | 215 | rebaseLockfile(pathToPackageRoot: string, lockfile: string) { 216 | const fileVersionMatcher = /[^"/]@(?:file:)?((?:\.\/|\.\.\/).*?)[":,]/gm; 217 | const replacements: Array<{ 218 | oldRef: string; 219 | newRef: string; 220 | }> = []; 221 | let match; 222 | 223 | // Detect all references and create replacement line strings 224 | // eslint-disable-next-line no-cond-assign 225 | while ((match = fileVersionMatcher.exec(lockfile)) !== null) { 226 | replacements.push({ 227 | oldRef: typeof match[1] === 'string' ? match[1] : '', 228 | newRef: replace(/\\/g, '/', `${pathToPackageRoot}/${match[1]}`), 229 | }); 230 | } 231 | 232 | // Replace all lines in lockfile 233 | return reduce( 234 | (__, replacement) => replace(replacement.oldRef, replacement.newRef, __), 235 | lockfile, 236 | replacements.filter((item) => item.oldRef !== '') 237 | ); 238 | } 239 | 240 | async install(cwd: string, extraArgs: Array, hasLockfile = true) { 241 | if (this.packagerOptions.noInstall) { 242 | return; 243 | } 244 | 245 | const version = await this.getVersion(cwd); 246 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 247 | 248 | const args = 249 | !this.packagerOptions.ignoreLockfile && hasLockfile 250 | ? ['install', ...(version.isBerry ? ['--immutable'] : ['--frozen-lockfile', '--non-interactive']), ...extraArgs] 251 | : ['install', ...(version.isBerry ? [] : ['--non-interactive']), ...extraArgs]; 252 | 253 | await spawnProcess(command, args, { cwd }); 254 | } 255 | 256 | // "Yarn install" prunes automatically 257 | prune(cwd: string) { 258 | return this.install(cwd, []); 259 | } 260 | 261 | async runScripts(cwd: string, scriptNames: string[]) { 262 | const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; 263 | 264 | await Promise.all(scriptNames.map((scriptName) => spawnProcess(command, ['run', scriptName], { cwd }))); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/pre-local.ts: -------------------------------------------------------------------------------- 1 | import { assertIsString } from './helper'; 2 | import type EsbuildServerlessPlugin from './index'; 3 | 4 | export function preLocal(this: EsbuildServerlessPlugin) { 5 | assertIsString(this.buildDirPath); 6 | 7 | this.serviceDirPath = this.buildDirPath; 8 | this.serverless.config.servicePath = this.buildDirPath; 9 | 10 | // If this is a node function set the service path as CWD to allow accessing bundled files correctly 11 | if (this.options.function && this.functions[this.options.function]) { 12 | process.chdir(this.serviceDirPath); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/pre-offline.ts: -------------------------------------------------------------------------------- 1 | import { relative } from 'path'; 2 | import { assocPath } from 'ramda'; 3 | import { assertIsString } from './helper'; 4 | 5 | import type EsbuildServerlessPlugin from './index'; 6 | 7 | export function preOffline(this: EsbuildServerlessPlugin) { 8 | assertIsString(this.buildDirPath); 9 | 10 | // Set offline location automatically if not set manually 11 | if (!this.serverless?.service?.custom?.['serverless-offline']?.location) { 12 | const newServerless = assocPath( 13 | ['service', 'custom', 'serverless-offline', 'location'], 14 | relative(this.serviceDirPath, this.buildDirPath), 15 | this.serverless 16 | ); 17 | 18 | this.serverless.service.custom = newServerless.service.custom; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/tests/bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild'; 2 | import pMap from 'p-map'; 3 | import type { PartialDeep } from 'type-fest'; 4 | 5 | import { bundle } from '../bundle'; 6 | 7 | import type { Configuration, FunctionBuildResult, FunctionEntry } from '../types'; 8 | import type EsbuildServerlessPlugin from '../index'; 9 | 10 | jest.mock('esbuild'); 11 | jest.mock('p-map'); 12 | 13 | const getBuild = async () => { 14 | return build; 15 | }; 16 | 17 | const esbuildPlugin = (override?: Partial): EsbuildServerlessPlugin => 18 | ({ 19 | prepare: jest.fn(), 20 | serverless: { 21 | cli: { 22 | log: jest.fn(), 23 | }, 24 | classes: { 25 | Error, 26 | }, 27 | }, 28 | buildOptions: { 29 | concurrency: Infinity, 30 | bundle: true, 31 | target: 'node12', 32 | external: [], 33 | exclude: ['aws-sdk'], 34 | nativeZip: false, 35 | packager: 'npm', 36 | installExtraArgs: [], 37 | watch: {}, 38 | keepOutputDirectory: false, 39 | packagerOptions: {}, 40 | platform: 'node', 41 | outputFileExtension: '.js', 42 | }, 43 | plugins: [], 44 | buildDirPath: '/workdir/.esbuild', 45 | functionEntries: [], 46 | log: { 47 | error: jest.fn(), 48 | warning: jest.fn(), 49 | notice: jest.fn(), 50 | info: jest.fn(), 51 | debug: jest.fn(), 52 | verbose: jest.fn(), 53 | success: jest.fn(), 54 | }, 55 | ...override, 56 | } as PartialDeep as EsbuildServerlessPlugin); 57 | 58 | beforeEach(() => { 59 | jest.mocked(pMap).mockImplementation((entries, mapper) => { 60 | return Promise.all((entries as string[]).map((entry, index) => mapper(entry, index))); 61 | }); 62 | }); 63 | 64 | afterEach(() => { 65 | jest.resetAllMocks(); 66 | }); 67 | 68 | it('should call esbuild only once when functions share the same entry', async () => { 69 | const functionEntries: FunctionEntry[] = [ 70 | { 71 | entry: 'file1.ts', 72 | func: { 73 | events: [], 74 | handler: 'file1.handler', 75 | }, 76 | functionAlias: 'func1', 77 | }, 78 | { 79 | entry: 'file1.ts', 80 | func: { 81 | events: [], 82 | handler: 'file1.handler2', 83 | }, 84 | functionAlias: 'func2', 85 | }, 86 | ]; 87 | 88 | await bundle.call(esbuildPlugin({ functionEntries })); 89 | 90 | const proxy = await getBuild(); 91 | expect(proxy).toHaveBeenCalledTimes(1); 92 | }); 93 | 94 | it('should only call esbuild multiple times when functions have different entries', async () => { 95 | const functionEntries: FunctionEntry[] = [ 96 | { 97 | entry: 'file1.ts', 98 | func: { 99 | events: [], 100 | handler: 'file1.handler', 101 | }, 102 | functionAlias: 'func1', 103 | }, 104 | { 105 | entry: 'file2.ts', 106 | func: { 107 | events: [], 108 | handler: 'file2.handler', 109 | }, 110 | functionAlias: 'func2', 111 | }, 112 | ]; 113 | 114 | await bundle.call(esbuildPlugin({ functionEntries })); 115 | 116 | const proxy = await getBuild(); 117 | expect(proxy).toHaveBeenCalledTimes(2); 118 | }); 119 | 120 | it('should set buildResults after compilation is complete', async () => { 121 | const functionEntries: FunctionEntry[] = [ 122 | { 123 | entry: 'file1.ts', 124 | func: { 125 | events: [], 126 | handler: 'file1.handler', 127 | }, 128 | functionAlias: 'func1', 129 | }, 130 | { 131 | entry: 'file2.ts', 132 | func: { 133 | events: [], 134 | handler: 'file2.handler', 135 | }, 136 | functionAlias: 'func2', 137 | }, 138 | ]; 139 | 140 | const expectedResults: FunctionBuildResult[] = [ 141 | { 142 | bundlePath: 'file1.js', 143 | func: { events: [], handler: 'file1.handler' }, 144 | functionAlias: 'func1', 145 | }, 146 | { 147 | bundlePath: 'file2.js', 148 | func: { events: [], handler: 'file2.handler' }, 149 | functionAlias: 'func2', 150 | }, 151 | ]; 152 | 153 | const plugin = esbuildPlugin({ functionEntries }); 154 | 155 | await bundle.call(plugin); 156 | 157 | expect(plugin.buildResults).toStrictEqual(expectedResults); 158 | }); 159 | 160 | it('should set the concurrency for pMap with the concurrency specified', async () => { 161 | const functionEntries: FunctionEntry[] = [ 162 | { 163 | entry: 'file1.ts', 164 | func: { 165 | events: [], 166 | handler: 'file1.handler', 167 | }, 168 | functionAlias: 'func1', 169 | }, 170 | ]; 171 | 172 | const plugin = esbuildPlugin({ functionEntries }); 173 | 174 | await bundle.call(plugin); 175 | 176 | expect(pMap).toHaveBeenCalledWith(expect.any(Array), expect.any(Function), { 177 | concurrency: Infinity, 178 | }); 179 | }); 180 | 181 | it('should filter out non esbuild options', async () => { 182 | const functionEntries: FunctionEntry[] = [ 183 | { 184 | entry: 'file1.ts', 185 | func: { 186 | events: [], 187 | handler: 'file1.handler', 188 | }, 189 | functionAlias: 'func1', 190 | }, 191 | ]; 192 | 193 | const plugin = esbuildPlugin({ functionEntries }); 194 | 195 | await bundle.call(plugin); 196 | 197 | const config: any = { 198 | bundle: true, 199 | entryPoints: ['file1.ts'], 200 | external: ['aws-sdk'], 201 | outdir: '/workdir/.esbuild', 202 | platform: 'node', 203 | plugins: [], 204 | target: 'node12', 205 | }; 206 | 207 | const proxy = await getBuild(); 208 | 209 | expect(proxy).toHaveBeenCalledWith(config); 210 | }); 211 | 212 | describe('buildOption platform node', () => { 213 | it('should set buildResults buildPath after compilation is complete with default extension', async () => { 214 | const functionEntries: FunctionEntry[] = [ 215 | { 216 | entry: 'file1.ts', 217 | func: { 218 | events: [], 219 | handler: 'file1.handler', 220 | }, 221 | functionAlias: 'func1', 222 | }, 223 | { 224 | entry: 'file2.ts', 225 | func: { 226 | events: [], 227 | handler: 'file2.handler', 228 | }, 229 | functionAlias: 'func2', 230 | }, 231 | ]; 232 | 233 | const expectedResults: FunctionBuildResult[] = [ 234 | { 235 | bundlePath: 'file1.js', 236 | func: { events: [], handler: 'file1.handler' }, 237 | functionAlias: 'func1', 238 | }, 239 | { 240 | bundlePath: 'file2.js', 241 | func: { events: [], handler: 'file2.handler' }, 242 | functionAlias: 'func2', 243 | }, 244 | ]; 245 | 246 | const plugin = esbuildPlugin({ functionEntries }); 247 | 248 | await bundle.call(plugin); 249 | 250 | expect(plugin.buildResults).toStrictEqual(expectedResults); 251 | }); 252 | 253 | it('should set buildResults buildPath after compilation is complete with ".cjs" extension', async () => { 254 | const functionEntries: FunctionEntry[] = [ 255 | { 256 | entry: 'file1.ts', 257 | func: { 258 | events: [], 259 | handler: 'file1.handler', 260 | }, 261 | functionAlias: 'func1', 262 | }, 263 | { 264 | entry: 'file2.ts', 265 | func: { 266 | events: [], 267 | handler: 'file2.handler', 268 | }, 269 | functionAlias: 'func2', 270 | }, 271 | ]; 272 | 273 | const buildOptions: Partial = { 274 | concurrency: Infinity, 275 | bundle: true, 276 | target: 'node12', 277 | external: [], 278 | exclude: ['aws-sdk'], 279 | nativeZip: false, 280 | packager: 'npm', 281 | installExtraArgs: [], 282 | watch: {}, 283 | keepOutputDirectory: false, 284 | packagerOptions: {}, 285 | platform: 'node', 286 | outputFileExtension: '.cjs', 287 | }; 288 | 289 | const expectedResults: FunctionBuildResult[] = [ 290 | { 291 | bundlePath: 'file1.cjs', 292 | func: { events: [], handler: 'file1.handler' }, 293 | functionAlias: 'func1', 294 | }, 295 | { 296 | bundlePath: 'file2.cjs', 297 | func: { events: [], handler: 'file2.handler' }, 298 | functionAlias: 'func2', 299 | }, 300 | ]; 301 | 302 | const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); 303 | 304 | await bundle.call(plugin); 305 | 306 | expect(plugin.buildResults).toStrictEqual(expectedResults); 307 | }); 308 | 309 | it('should error when trying to use ".mjs" extension', async () => { 310 | const functionEntries: FunctionEntry[] = [ 311 | { 312 | entry: 'file1.ts', 313 | func: { 314 | events: [], 315 | handler: 'file1.handler', 316 | }, 317 | functionAlias: 'func1', 318 | }, 319 | { 320 | entry: 'file2.ts', 321 | func: { 322 | events: [], 323 | handler: 'file2.handler', 324 | }, 325 | functionAlias: 'func2', 326 | }, 327 | ]; 328 | 329 | const buildOptions: Partial = { 330 | concurrency: Infinity, 331 | bundle: true, 332 | target: 'node12', 333 | external: [], 334 | exclude: ['aws-sdk'], 335 | nativeZip: false, 336 | packager: 'npm', 337 | installExtraArgs: [], 338 | watch: {}, 339 | keepOutputDirectory: false, 340 | packagerOptions: {}, 341 | platform: 'node', 342 | outputFileExtension: '.mjs', 343 | }; 344 | 345 | const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); 346 | 347 | const expectedError = 'ERROR: Non esm builds should not output a file with extension ".mjs".'; 348 | 349 | try { 350 | await bundle.call(plugin); 351 | } catch (error) { 352 | expect(error).toHaveProperty('message', expectedError); 353 | } 354 | }); 355 | }); 356 | 357 | describe('buildOption platform neutral', () => { 358 | it('should set buildResults buildPath after compilation is complete with default extension', async () => { 359 | const functionEntries: FunctionEntry[] = [ 360 | { 361 | entry: 'file1.ts', 362 | func: { 363 | events: [], 364 | handler: 'file1.handler', 365 | }, 366 | functionAlias: 'func1', 367 | }, 368 | { 369 | entry: 'file2.ts', 370 | func: { 371 | events: [], 372 | handler: 'file2.handler', 373 | }, 374 | functionAlias: 'func2', 375 | }, 376 | ]; 377 | 378 | const buildOptions: Partial = { 379 | concurrency: Infinity, 380 | bundle: true, 381 | target: 'node12', 382 | external: [], 383 | exclude: ['aws-sdk'], 384 | nativeZip: false, 385 | packager: 'npm', 386 | installExtraArgs: [], 387 | watch: {}, 388 | keepOutputDirectory: false, 389 | packagerOptions: {}, 390 | platform: 'neutral', 391 | outputFileExtension: '.js', 392 | }; 393 | 394 | const expectedResults: FunctionBuildResult[] = [ 395 | { 396 | bundlePath: 'file1.js', 397 | func: { events: [], handler: 'file1.handler' }, 398 | functionAlias: 'func1', 399 | }, 400 | { 401 | bundlePath: 'file2.js', 402 | func: { events: [], handler: 'file2.handler' }, 403 | functionAlias: 'func2', 404 | }, 405 | ]; 406 | 407 | const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); 408 | 409 | await bundle.call(plugin); 410 | 411 | expect(plugin.buildResults).toStrictEqual(expectedResults); 412 | }); 413 | 414 | it('should set buildResults buildPath after compilation is complete with ".mjs" extension', async () => { 415 | const functionEntries: FunctionEntry[] = [ 416 | { 417 | entry: 'file1.ts', 418 | func: { 419 | events: [], 420 | handler: 'file1.handler', 421 | }, 422 | functionAlias: 'func1', 423 | }, 424 | { 425 | entry: 'file2.ts', 426 | func: { 427 | events: [], 428 | handler: 'file2.handler', 429 | }, 430 | functionAlias: 'func2', 431 | }, 432 | ]; 433 | 434 | const buildOptions: Partial = { 435 | concurrency: Infinity, 436 | bundle: true, 437 | target: 'node12', 438 | external: [], 439 | exclude: ['aws-sdk'], 440 | nativeZip: false, 441 | packager: 'npm', 442 | installExtraArgs: [], 443 | watch: {}, 444 | keepOutputDirectory: false, 445 | packagerOptions: {}, 446 | platform: 'neutral', 447 | outputFileExtension: '.mjs', 448 | }; 449 | 450 | const expectedResults: FunctionBuildResult[] = [ 451 | { 452 | bundlePath: 'file1.mjs', 453 | func: { events: [], handler: 'file1.handler' }, 454 | functionAlias: 'func1', 455 | }, 456 | { 457 | bundlePath: 'file2.mjs', 458 | func: { events: [], handler: 'file2.handler' }, 459 | functionAlias: 'func2', 460 | }, 461 | ]; 462 | 463 | const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); 464 | 465 | await bundle.call(plugin); 466 | 467 | expect(plugin.buildResults).toStrictEqual(expectedResults); 468 | }); 469 | 470 | it('should error when trying to use ".cjs" extension', async () => { 471 | const functionEntries: FunctionEntry[] = [ 472 | { 473 | entry: 'file1.ts', 474 | func: { 475 | events: [], 476 | handler: 'file1.handler', 477 | }, 478 | functionAlias: 'func1', 479 | }, 480 | { 481 | entry: 'file2.ts', 482 | func: { 483 | events: [], 484 | handler: 'file2.handler', 485 | }, 486 | functionAlias: 'func2', 487 | }, 488 | ]; 489 | 490 | const buildOptions: Partial = { 491 | concurrency: Infinity, 492 | bundle: true, 493 | target: 'node12', 494 | external: [], 495 | exclude: ['aws-sdk'], 496 | nativeZip: false, 497 | packager: 'npm', 498 | installExtraArgs: [], 499 | watch: {}, 500 | keepOutputDirectory: false, 501 | packagerOptions: {}, 502 | platform: 'neutral', 503 | outputFileExtension: '.cjs', 504 | }; 505 | 506 | const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); 507 | 508 | const expectedError = 'ERROR: format "esm" or platform "neutral" should not output a file with extension ".cjs".'; 509 | 510 | try { 511 | await bundle.call(plugin); 512 | } catch (error) { 513 | expect(error).toHaveProperty('message', expectedError); 514 | } 515 | }); 516 | }); 517 | -------------------------------------------------------------------------------- /src/tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import type Serverless from 'serverless'; 3 | import type Service from 'serverless/classes/Service'; 4 | 5 | import EsbuildServerlessPlugin from '../index'; 6 | import type { ImprovedServerlessOptions } from '../types'; 7 | 8 | jest.mock('fs-extra'); 9 | 10 | const mockProvider: Service['provider'] = { 11 | name: 'aws', 12 | region: 'us-east-1', 13 | stage: 'dev', 14 | runtime: 'nodejs18.x', 15 | compiledCloudFormationTemplate: { 16 | Resources: {}, 17 | }, 18 | versionFunctions: true, 19 | }; 20 | 21 | const mockGetFunction = jest.fn(); 22 | 23 | const packageIndividuallyService: Partial = { 24 | functions: { 25 | hello1: { handler: 'hello1.handler', events: [], package: { artifact: 'hello1' } }, 26 | hello2: { handler: 'hello2.handler', events: [], package: { artifact: 'hello2' } }, 27 | }, 28 | package: { individually: true }, 29 | provider: mockProvider, 30 | getFunction: mockGetFunction, 31 | }; 32 | 33 | const packageService: Partial = { 34 | functions: { 35 | hello1: { handler: 'hello1.handler', events: [] }, 36 | hello2: { handler: 'hello2.handler', events: [] }, 37 | }, 38 | package: { artifact: 'hello' }, 39 | provider: mockProvider, 40 | getFunction: mockGetFunction, 41 | }; 42 | 43 | const patternsService: Partial = { 44 | functions: { 45 | hello1: { handler: 'hello1.handler', events: [] }, 46 | hello2: { handler: 'hello2.handler', events: [], package: {} }, 47 | hello3: { handler: 'hello3.handler', events: [], package: { patterns: ['excluded-by-default.json'] } }, 48 | }, 49 | package: { patterns: ['!excluded-by-default.json'] }, 50 | provider: mockProvider, 51 | getFunction: mockGetFunction, 52 | }; 53 | 54 | const mockServerlessConfig = (serviceOverride?: Partial): Serverless => { 55 | const service = { 56 | ...packageIndividuallyService, 57 | ...serviceOverride, 58 | } as Service; 59 | 60 | const mockCli = { 61 | log: jest.fn(), 62 | }; 63 | 64 | return { 65 | service, 66 | config: { 67 | servicePath: '/workDir', 68 | serviceDir: '/workDir', 69 | }, 70 | configSchemaHandler: { 71 | defineCustomProperties: jest.fn(), 72 | defineFunctionEvent: jest.fn(), 73 | defineFunctionEventProperties: jest.fn(), 74 | defineFunctionProperties: jest.fn(), 75 | defineProvider: jest.fn(), 76 | defineTopLevelProperty: jest.fn(), 77 | }, 78 | cli: mockCli, 79 | } as Partial as Serverless; 80 | }; 81 | 82 | const mockOptions: ImprovedServerlessOptions = { 83 | region: 'us-east-1', 84 | stage: 'dev', 85 | }; 86 | 87 | afterEach(() => { 88 | jest.resetAllMocks(); 89 | }); 90 | 91 | describe('Move Artifacts', () => { 92 | it('should copy files from the esbuild folder to the serverless folder', async () => { 93 | const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(), mockOptions); 94 | 95 | plugin.hooks.initialize?.(); 96 | 97 | await plugin.moveArtifacts(); 98 | 99 | expect(fs.copy).toHaveBeenCalledWith('/workDir/.esbuild/.serverless', '/workDir/.serverless'); 100 | }); 101 | 102 | describe('function option', () => { 103 | it('should update the selected functions base path to the serverless folder', async () => { 104 | mockGetFunction.mockReturnValue(packageIndividuallyService.functions?.hello1); 105 | const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(), { 106 | ...mockOptions, 107 | function: 'hello1', 108 | }); 109 | 110 | plugin.hooks.initialize?.(); 111 | 112 | await plugin.moveArtifacts(); 113 | 114 | expect(plugin.functions).toMatchInlineSnapshot(` 115 | { 116 | "hello1": { 117 | "events": [], 118 | "handler": "hello1.handler", 119 | "package": { 120 | "artifact": ".serverless/hello1", 121 | }, 122 | }, 123 | } 124 | `); 125 | }); 126 | }); 127 | 128 | describe('package individually', () => { 129 | it('should update function package artifacts base path to the serverless folder', async () => { 130 | const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(), mockOptions); 131 | 132 | plugin.hooks.initialize?.(); 133 | 134 | await plugin.moveArtifacts(); 135 | 136 | expect(plugin.functions).toMatchInlineSnapshot(` 137 | { 138 | "hello1": { 139 | "events": [], 140 | "handler": "hello1.handler", 141 | "package": { 142 | "artifact": ".serverless/hello1", 143 | }, 144 | }, 145 | "hello2": { 146 | "events": [], 147 | "handler": "hello2.handler", 148 | "package": { 149 | "artifact": ".serverless/hello2", 150 | }, 151 | }, 152 | } 153 | `); 154 | }); 155 | 156 | it('should only update the base path of node functions', async () => { 157 | const plugin = new EsbuildServerlessPlugin( 158 | mockServerlessConfig({ 159 | functions: { 160 | ...packageIndividuallyService.functions, 161 | hello3: { handler: 'hello3.handler', events: [], runtime: 'python2.7' }, 162 | }, 163 | }), 164 | mockOptions 165 | ); 166 | 167 | plugin.hooks.initialize?.(); 168 | 169 | await plugin.moveArtifacts(); 170 | 171 | expect(plugin.functions).toMatchInlineSnapshot(` 172 | { 173 | "hello1": { 174 | "events": [], 175 | "handler": "hello1.handler", 176 | "package": { 177 | "artifact": ".serverless/hello1", 178 | }, 179 | }, 180 | "hello2": { 181 | "events": [], 182 | "handler": "hello2.handler", 183 | "package": { 184 | "artifact": ".serverless/hello2", 185 | }, 186 | }, 187 | } 188 | `); 189 | }); 190 | 191 | it('should skip function if skipEsbuild is set to true', async () => { 192 | jest.mocked(fs.existsSync).mockReturnValue(true); 193 | 194 | const hello3 = { handler: 'hello3.handler', events: [], skipEsbuild: true }; 195 | const plugin = new EsbuildServerlessPlugin( 196 | mockServerlessConfig({ 197 | functions: { 198 | ...packageIndividuallyService.functions, 199 | hello3, 200 | }, 201 | }), 202 | mockOptions 203 | ); 204 | plugin.hooks.initialize?.(); 205 | 206 | await plugin.moveArtifacts(); 207 | 208 | expect(plugin.functions).toMatchInlineSnapshot(` 209 | { 210 | "hello1": { 211 | "events": [], 212 | "handler": "hello1.handler", 213 | "package": { 214 | "artifact": ".serverless/hello1", 215 | }, 216 | }, 217 | "hello2": { 218 | "events": [], 219 | "handler": "hello2.handler", 220 | "package": { 221 | "artifact": ".serverless/hello2", 222 | }, 223 | }, 224 | "hello3": { 225 | "events": [], 226 | "handler": "hello3.handler", 227 | "skipEsbuild": true, 228 | }, 229 | } 230 | `); 231 | 232 | expect(plugin.functionEntries).toEqual( 233 | expect.arrayContaining([ 234 | { 235 | entry: expect.stringContaining('/hello1.ts'), 236 | func: { 237 | events: [], 238 | handler: 'hello1.handler', 239 | package: { 240 | artifact: '.serverless/hello1', 241 | }, 242 | }, 243 | functionAlias: 'hello1', 244 | }, 245 | { 246 | entry: expect.stringContaining('/hello2.ts'), 247 | func: { 248 | events: [], 249 | handler: 'hello2.handler', 250 | package: { 251 | artifact: '.serverless/hello2', 252 | }, 253 | }, 254 | functionAlias: 'hello2', 255 | }, 256 | ]) 257 | ); 258 | expect(plugin.functionEntries).not.toContain( 259 | expect.objectContaining({ 260 | functionAlias: 'hello3', 261 | }) 262 | ); 263 | }); 264 | }); 265 | 266 | describe('service package', () => { 267 | it('should update the service package artifact base path to the serverless folder', async () => { 268 | const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(packageService), mockOptions); 269 | 270 | plugin.hooks.initialize?.(); 271 | 272 | await plugin.moveArtifacts(); 273 | 274 | expect(plugin.serverless.service.package.artifact).toBe('.serverless/hello'); 275 | }); 276 | }); 277 | }); 278 | 279 | describe('Prepare', () => { 280 | describe('function package', () => { 281 | it('should set package patterns on functions only if supplied', () => { 282 | const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(patternsService), mockOptions); 283 | 284 | plugin.hooks.initialize?.(); 285 | 286 | plugin.prepare(); 287 | 288 | expect(plugin.functions).toMatchInlineSnapshot(` 289 | { 290 | "hello1": { 291 | "events": [], 292 | "handler": "hello1.handler", 293 | "package": {}, 294 | }, 295 | "hello2": { 296 | "events": [], 297 | "handler": "hello2.handler", 298 | "package": {}, 299 | }, 300 | "hello3": { 301 | "events": [], 302 | "handler": "hello3.handler", 303 | "package": { 304 | "patterns": [ 305 | "excluded-by-default.json", 306 | ], 307 | }, 308 | }, 309 | } 310 | `); 311 | }); 312 | 313 | it('should copy the previous build resources if skipBuild is true', async () => { 314 | const skipBuildServerlessConfig = { 315 | ...patternsService, 316 | custom: { 317 | esbuild: { 318 | skipBuild: true, 319 | }, 320 | }, 321 | }; 322 | const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(skipBuildServerlessConfig), mockOptions); 323 | const copyPreBuiltResourcesSpy = jest.spyOn(plugin, 'copyPreBuiltResources'); 324 | const prepareSpy = jest.spyOn(plugin, 'prepare'); 325 | 326 | plugin.hooks.initialize?.(); 327 | expect(copyPreBuiltResourcesSpy).toHaveBeenCalled(); 328 | expect(prepareSpy).toHaveBeenCalled(); 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /src/tests/pack.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import globby from 'globby'; 3 | import pMap from 'p-map'; 4 | 5 | import { filterFilesForZipPackage, pack, copyPreBuiltResources } from '../pack'; 6 | import * as utils from '../utils'; 7 | import type { EsbuildFunctionDefinitionHandler, FunctionBuildResult } from '../types'; 8 | import type EsbuildServerlessPlugin from '../index'; 9 | import { SERVERLESS_FOLDER } from '../constants'; 10 | 11 | jest.mock('globby'); 12 | jest.mock('fs-extra'); 13 | jest.mock('p-map'); 14 | 15 | const mockCli = { 16 | log: jest.fn(), 17 | }; 18 | 19 | describe('filterFilesForZipPackage', () => { 20 | it('should filter out files for another zip package', () => { 21 | expect( 22 | filterFilesForZipPackage({ 23 | files: [ 24 | { 25 | localPath: '__only_service-otherFnName/bin/imagemagick/include/ImageMagick/magick/method-attribute.h', 26 | rootPath: 27 | '/home/capaj/repos/google/search/.esbuild/.build/__only_service-otherFnName/bin/imagemagick/include/ImageMagick/magick/method-attribute.h', 28 | }, 29 | 30 | { 31 | localPath: '__only_fnAlias/bin/imagemagick/include/ImageMagick/magick/method-attribute.h', 32 | rootPath: 33 | '/home/capaj/repos/google/search/.esbuild/.build/__only_fnAlias/bin/imagemagick/include/ImageMagick/magick/method-attribute.h', 34 | }, 35 | ], 36 | 37 | depWhiteList: [], 38 | functionAlias: 'fnAlias', 39 | isGoogleProvider: false, 40 | hasExternals: false, 41 | includedFiles: [], 42 | excludedFiles: [], 43 | }) 44 | ).toMatchInlineSnapshot(` 45 | [ 46 | { 47 | "localPath": "__only_fnAlias/bin/imagemagick/include/ImageMagick/magick/method-attribute.h", 48 | "rootPath": "/home/capaj/repos/google/search/.esbuild/.build/__only_fnAlias/bin/imagemagick/include/ImageMagick/magick/method-attribute.h", 49 | }, 50 | ] 51 | `); 52 | }); 53 | }); 54 | 55 | describe('pack', () => { 56 | beforeEach(() => { 57 | jest.mocked(globby).sync.mockReturnValue(['hello1.js', 'hello2.js']); 58 | jest.mocked(globby).mockResolvedValue([]); 59 | jest.mocked(fs).statSync.mockReturnValue({ size: 123 } as fs.Stats); 60 | 61 | jest.mocked(pMap).mockImplementation((entries, mapper) => { 62 | return Promise.all((entries as string[]).map((entry, index) => mapper(entry, index))); 63 | }); 64 | }); 65 | 66 | afterEach(() => { 67 | jest.clearAllMocks(); 68 | }); 69 | 70 | describe('individually', () => { 71 | it('should create zips with the functionAlias as the name', async () => { 72 | const buildResults: FunctionBuildResult[] = [ 73 | { 74 | bundlePath: 'hello1.js', 75 | func: { 76 | handler: 'hello1.handler', 77 | events: [{ http: { path: 'hello', method: 'get' } }], 78 | name: 'serverless-example-dev-hello1', 79 | package: {}, 80 | }, 81 | functionAlias: 'hello1', 82 | }, 83 | { 84 | bundlePath: 'hello2.js', 85 | func: { 86 | handler: 'hello2.handler', 87 | events: [{ http: { path: 'hello', method: 'get' } }], 88 | name: 'serverless-example-dev-hello2', 89 | package: {}, 90 | }, 91 | functionAlias: 'hello2', 92 | }, 93 | ]; 94 | 95 | const esbuildPlugin = { 96 | buildResults, 97 | serverless: { 98 | service: { 99 | package: { 100 | individually: true, 101 | }, 102 | }, 103 | cli: mockCli, 104 | getVersion: jest.fn().mockReturnValue('3.28.1'), 105 | }, 106 | buildOptions: { 107 | zipConcurrency: Infinity, 108 | packager: 'yarn', 109 | exclude: ['aws-sdk'], 110 | external: [], 111 | nativeZip: false, 112 | }, 113 | buildDirPath: '/workdir/serverless-esbuild/examples/individually/.esbuild/.build', 114 | workDirPath: '/workdir/serverless-esbuild/examples/individually/.esbuild/', 115 | serviceDirPath: '/workdir/serverless-esbuild/examples/individually', 116 | log: { 117 | error: jest.fn(), 118 | warning: jest.fn(), 119 | notice: jest.fn(), 120 | info: jest.fn(), 121 | debug: jest.fn(), 122 | verbose: jest.fn(), 123 | success: jest.fn(), 124 | }, 125 | } as unknown as EsbuildServerlessPlugin; 126 | 127 | const zipSpy = jest.spyOn(utils, 'zip').mockResolvedValue(); 128 | 129 | await pack.call(esbuildPlugin); 130 | 131 | expect(zipSpy).toHaveBeenCalledWith( 132 | '/workdir/serverless-esbuild/examples/individually/.esbuild/.serverless/hello1.zip', 133 | expect.any(Array), 134 | expect.any(Boolean) 135 | ); 136 | expect(zipSpy).toHaveBeenCalledWith( 137 | '/workdir/serverless-esbuild/examples/individually/.esbuild/.serverless/hello2.zip', 138 | expect.any(Array), 139 | expect.any(Boolean) 140 | ); 141 | }); 142 | 143 | it('should call pMap with the right concurrency', async () => { 144 | const buildResults: FunctionBuildResult[] = [ 145 | { 146 | bundlePath: 'hello1.js', 147 | func: { 148 | handler: 'hello1.handler', 149 | events: [{ http: { path: 'hello', method: 'get' } }], 150 | name: 'serverless-example-dev-hello1', 151 | package: {}, 152 | }, 153 | functionAlias: 'hello1', 154 | }, 155 | { 156 | bundlePath: 'hello2.js', 157 | func: { 158 | handler: 'hello2.handler', 159 | events: [{ http: { path: 'hello', method: 'get' } }], 160 | name: 'serverless-example-dev-hello2', 161 | package: {}, 162 | }, 163 | functionAlias: 'hello2', 164 | }, 165 | ]; 166 | 167 | const esbuildPlugin = { 168 | buildResults, 169 | serverless: { 170 | service: { 171 | package: { 172 | individually: true, 173 | }, 174 | }, 175 | cli: mockCli, 176 | getVersion: jest.fn().mockReturnValue('3.28.1'), 177 | }, 178 | buildOptions: { 179 | zipConcurrency: Infinity, 180 | packager: 'yarn', 181 | exclude: ['aws-sdk'], 182 | external: [], 183 | nativeZip: false, 184 | }, 185 | buildDirPath: '/workdir/serverless-esbuild/examples/individually/.esbuild/.build', 186 | workDirPath: '/workdir/serverless-esbuild/examples/individually/.esbuild/', 187 | serviceDirPath: '/workdir/serverless-esbuild/examples/individually', 188 | log: { 189 | error: jest.fn(), 190 | warning: jest.fn(), 191 | notice: jest.fn(), 192 | info: jest.fn(), 193 | debug: jest.fn(), 194 | verbose: jest.fn(), 195 | success: jest.fn(), 196 | }, 197 | } as unknown as EsbuildServerlessPlugin; 198 | 199 | await pack.call(esbuildPlugin); 200 | 201 | expect(pMap).toHaveBeenCalledWith(expect.any(Array), expect.any(Function), { 202 | concurrency: Infinity, 203 | }); 204 | }); 205 | }); 206 | }); 207 | 208 | describe('copyPreBuiltResources', () => { 209 | afterEach(() => { 210 | jest.clearAllMocks(); 211 | }); 212 | function getMockEsbuildPlugin(bundleIndividually = true): EsbuildServerlessPlugin { 213 | return { 214 | functions: { 215 | hello1: { handler: 'hello1.handler', events: [], package: { artifact: 'hello1' }, skipEsbuild: true }, 216 | hello2: { handler: 'hello2.handler', events: [], package: { artifact: 'hello2' }, skipEsbuild: true }, 217 | }, 218 | serverless: { 219 | service: { 220 | service: 'testApp', 221 | package: { 222 | individually: bundleIndividually, 223 | }, 224 | }, 225 | cli: mockCli, 226 | getVersion: jest.fn().mockReturnValue('3.28.1'), 227 | }, 228 | buildOptions: { 229 | zipConcurrency: Infinity, 230 | packager: 'yarn', 231 | exclude: ['aws-sdk'], 232 | external: [], 233 | nativeZip: false, 234 | }, 235 | buildDirPath: '/workdir/serverless-esbuild/examples/individually/.esbuild/.build', 236 | workDirPath: '/workdir/serverless-esbuild/examples/individually/.esbuild/', 237 | serviceDirPath: '/workdir/serverless-esbuild/examples/individually', 238 | packageOutputPath: '/workdir/serverless-esbuild/examples/minimal', 239 | log: { 240 | error: jest.fn(), 241 | warning: jest.fn(), 242 | notice: jest.fn(), 243 | info: jest.fn(), 244 | debug: jest.fn(), 245 | verbose: jest.fn(), 246 | success: jest.fn(), 247 | }, 248 | } as unknown as EsbuildServerlessPlugin; 249 | } 250 | 251 | it('should copy the single artifact if not bundling individually', async () => { 252 | const mockEsbuildPlugin = getMockEsbuildPlugin(false); 253 | 254 | await copyPreBuiltResources.call(mockEsbuildPlugin); 255 | 256 | expect(mockEsbuildPlugin.serverless.service.package.artifact).toEqual( 257 | `${mockEsbuildPlugin.workDirPath}${SERVERLESS_FOLDER}/${mockEsbuildPlugin.serverless.service.service}.zip` 258 | ); 259 | expect(fs.copy).toHaveBeenCalledTimes(1); 260 | }); 261 | 262 | it('should copy the artifacts for all of the functions when bundling individually', async () => { 263 | const mockEsbuildPlugin = getMockEsbuildPlugin(); 264 | 265 | await copyPreBuiltResources.call(mockEsbuildPlugin); 266 | 267 | expect(mockEsbuildPlugin.serverless.service.package.artifact).not.toBeDefined(); 268 | expect(fs.copy).toHaveBeenCalledTimes(2); 269 | Object.keys(mockEsbuildPlugin.functions).forEach((functionAlias) => { 270 | const func = mockEsbuildPlugin.functions[functionAlias]; 271 | expect(func?.package?.artifact).toEqual(`.esbuild/${SERVERLESS_FOLDER}/${functionAlias}.zip`); 272 | }); 273 | }); 274 | 275 | it('should not copy over artifacts for functions where `skipEsBuild` is false', async () => { 276 | const mockEsbuildPlugin = getMockEsbuildPlugin(); 277 | mockEsbuildPlugin.functions.hello3 = { 278 | handler: 'hello3.handler', 279 | events: [], 280 | package: { artifact: 'hello3' }, 281 | skipEsbuild: false, 282 | } as EsbuildFunctionDefinitionHandler; 283 | 284 | await copyPreBuiltResources.call(mockEsbuildPlugin); 285 | 286 | expect(mockEsbuildPlugin.serverless.service.package.artifact).not.toBeDefined(); 287 | expect(fs.copy).toHaveBeenCalledTimes(2); 288 | Object.keys(mockEsbuildPlugin.functions).forEach((functionAlias) => { 289 | const func = mockEsbuildPlugin.functions[functionAlias] as EsbuildFunctionDefinitionHandler; 290 | if (func.skipEsbuild) { 291 | expect(func?.package?.artifact).toEqual(`.esbuild/${SERVERLESS_FOLDER}/${functionAlias}.zip`); 292 | } 293 | }); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /src/tests/packagers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getPackager } from '../../packagers'; 2 | 3 | import type EsbuildServerlessPlugin from '../../index'; 4 | 5 | describe('getPackager()', () => { 6 | const mockPlugin = { 7 | log: { 8 | debug: jest.fn(), 9 | }, 10 | } as unknown as EsbuildServerlessPlugin; 11 | 12 | it('Returns a Packager instance', async () => { 13 | const npm = await getPackager.call(mockPlugin, 'npm', {}); 14 | 15 | expect(npm).toEqual(expect.any(Object)); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/tests/packagers/yarn.test.ts: -------------------------------------------------------------------------------- 1 | import { Yarn } from '../../packagers/yarn'; 2 | import type { YarnDeps } from '../../packagers/yarn'; 3 | import type { DependenciesResult } from '../../types'; 4 | 5 | import * as utils from '../../utils'; 6 | 7 | jest.mock('process'); 8 | describe('Yarn Packager', () => { 9 | const yarn = new Yarn({}); 10 | const path = './'; 11 | 12 | let spawnSpy: jest.SpyInstance; 13 | 14 | beforeEach(() => { 15 | spawnSpy = jest.spyOn(utils, 'spawnProcess'); 16 | }); 17 | 18 | afterEach(() => { 19 | jest.resetAllMocks(); 20 | jest.restoreAllMocks(); 21 | }); 22 | 23 | it('should call spawnProcess with the correct arguments for listing yarn dependencies', async () => { 24 | spawnSpy.mockResolvedValueOnce({ 25 | stderr: '', 26 | stdout: '{"type":"tree","data":{"type":"list","trees":[]}}', 27 | }); 28 | 29 | await yarn.getProdDependencies(path); 30 | 31 | expect(spawnSpy).toHaveBeenCalledTimes(1); 32 | expect(spawnSpy).toHaveBeenCalledWith('yarn', ['list', '--json', '--production'], { cwd: './' }); 33 | }); 34 | 35 | it('should call spawnProcess with the correct arguments for listing yarn dependencies when depth is provided', async () => { 36 | spawnSpy.mockResolvedValueOnce({ 37 | stderr: '', 38 | stdout: '{"type":"tree","data":{"type":"list","trees":[]}}', 39 | }); 40 | 41 | await yarn.getProdDependencies(path, 2); 42 | 43 | expect(spawnSpy).toHaveBeenCalledTimes(1); 44 | expect(spawnSpy).toHaveBeenCalledWith('yarn', ['list', '--depth=2', '--json', '--production'], { 45 | cwd: './', 46 | }); 47 | }); 48 | 49 | it('should create a dependency tree from yarn output', async () => { 50 | const yarnOutput: YarnDeps = { 51 | type: 'tree', 52 | data: { 53 | type: 'list', 54 | trees: [ 55 | { 56 | name: 'samchungy-a@2.0.0', 57 | children: [ 58 | { 59 | name: 'samchungy-dep-a@1.0.0', 60 | color: 'dim', 61 | shadow: true, 62 | }, 63 | ], 64 | hint: null, 65 | color: 'bold', 66 | depth: 0, 67 | }, 68 | { 69 | name: 'samchungy-b@2.0.0', 70 | children: [ 71 | { 72 | name: 'samchungy-dep-a@2.0.0', 73 | color: 'dim', 74 | shadow: true, 75 | }, 76 | { 77 | name: 'samchungy-dep-a@2.0.0', 78 | children: [], 79 | hint: null, 80 | color: 'bold', 81 | depth: 0, 82 | }, 83 | ], 84 | hint: null, 85 | color: 'bold', 86 | depth: 0, 87 | }, 88 | { 89 | name: 'samchungy-dep-a@1.0.0', 90 | children: [], 91 | hint: null, 92 | color: null, 93 | depth: 0, 94 | }, 95 | ], 96 | }, 97 | }; 98 | const expectedResult: DependenciesResult = { 99 | dependencies: { 100 | 'samchungy-a': { 101 | dependencies: { 102 | 'samchungy-dep-a': { 103 | isRootDep: true, 104 | version: '1.0.0', 105 | }, 106 | }, 107 | version: '2.0.0', 108 | }, 109 | 'samchungy-b': { 110 | dependencies: { 111 | 'samchungy-dep-a': { 112 | version: '2.0.0', 113 | }, 114 | }, 115 | version: '2.0.0', 116 | }, 117 | 'samchungy-dep-a': { 118 | version: '1.0.0', 119 | }, 120 | }, 121 | }; 122 | 123 | spawnSpy.mockResolvedValueOnce({ 124 | stderr: '', 125 | stdout: JSON.stringify(yarnOutput), 126 | }); 127 | 128 | const result = await yarn.getProdDependencies(path, 2); 129 | 130 | expect(result).toStrictEqual(expectedResult); 131 | }); 132 | 133 | it('should create a dependency tree which handles deduping from yarn output', async () => { 134 | const yarnOutput: YarnDeps = { 135 | type: 'tree', 136 | data: { 137 | type: 'list', 138 | trees: [ 139 | { 140 | name: 'samchungy-a@3.0.0', 141 | children: [{ name: 'samchungy-dep-b@3.0.0', color: 'dim', shadow: true }], 142 | hint: null, 143 | color: 'bold', 144 | depth: 0, 145 | }, 146 | { 147 | name: 'samchungy-b@5.0.0', 148 | children: [{ name: 'samchungy-dep-b@3.0.0', color: 'dim', shadow: true }], 149 | hint: null, 150 | color: 'bold', 151 | depth: 0, 152 | }, 153 | { 154 | name: 'samchungy-dep-b@3.0.0', 155 | children: [ 156 | { name: 'samchungy-dep-c@^1.0.0', color: 'dim', shadow: true }, 157 | { name: 'samchungy-dep-d@^1.0.0', color: 'dim', shadow: true }, 158 | ], 159 | hint: null, 160 | color: null, 161 | depth: 0, 162 | }, 163 | { 164 | name: 'samchungy-dep-c@1.0.0', 165 | children: [{ name: 'samchungy-dep-e@^1.0.0', color: 'dim', shadow: true }], 166 | hint: null, 167 | color: null, 168 | depth: 0, 169 | }, 170 | { 171 | name: 'samchungy-dep-d@1.0.0', 172 | children: [{ name: 'samchungy-dep-e@^1.0.0', color: 'dim', shadow: true }], 173 | hint: null, 174 | color: null, 175 | depth: 0, 176 | }, 177 | { 178 | name: 'samchungy-dep-e@1.0.0', 179 | children: [], 180 | hint: null, 181 | color: null, 182 | depth: 0, 183 | }, 184 | ], 185 | }, 186 | }; 187 | 188 | const expectedResult: DependenciesResult = { 189 | dependencies: { 190 | 'samchungy-a': { 191 | version: '3.0.0', 192 | dependencies: { 193 | 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, 194 | }, 195 | }, 196 | 'samchungy-b': { 197 | version: '5.0.0', 198 | dependencies: { 199 | 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, 200 | }, 201 | }, 202 | 'samchungy-dep-b': { 203 | version: '3.0.0', 204 | dependencies: { 205 | 'samchungy-dep-c': { version: '^1.0.0', isRootDep: true }, 206 | 'samchungy-dep-d': { version: '^1.0.0', isRootDep: true }, 207 | }, 208 | }, 209 | 'samchungy-dep-c': { 210 | version: '1.0.0', 211 | dependencies: { 212 | 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, 213 | }, 214 | }, 215 | 'samchungy-dep-d': { 216 | version: '1.0.0', 217 | dependencies: { 218 | 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, 219 | }, 220 | }, 221 | 'samchungy-dep-e': { version: '1.0.0' }, 222 | }, 223 | }; 224 | 225 | spawnSpy.mockResolvedValueOnce({ 226 | stderr: '', 227 | stdout: JSON.stringify(yarnOutput), 228 | }); 229 | 230 | const result = await yarn.getProdDependencies(path, 2); 231 | 232 | expect(result).toStrictEqual(expectedResult); 233 | }); 234 | 235 | it('should skip install if the noInstall option is true', async () => { 236 | const yarnWithoutInstall = new Yarn({ 237 | noInstall: true, 238 | }); 239 | 240 | await expect(yarnWithoutInstall.install(path, [], false)).resolves.toBeUndefined(); 241 | expect(spawnSpy).toHaveBeenCalledTimes(0); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /src/tests/pre-local.test.ts: -------------------------------------------------------------------------------- 1 | import { preLocal } from '../pre-local'; 2 | 3 | import type EsbuildServerlessPlugin from '../index'; 4 | 5 | const chdirSpy = jest.spyOn(process, 'chdir').mockImplementation(); 6 | 7 | afterEach(() => { 8 | jest.resetAllMocks(); 9 | }); 10 | 11 | it('should call chdir with the buildDirPath if the invoked function is a node function', () => { 12 | const esbuildPlugin = { 13 | buildDirPath: 'workdir/.build', 14 | serverless: { 15 | config: {}, 16 | }, 17 | options: { 18 | function: 'hello', 19 | }, 20 | functions: { 21 | hello: {}, 22 | }, 23 | }; 24 | 25 | preLocal.call(esbuildPlugin as unknown as EsbuildServerlessPlugin); 26 | 27 | expect(chdirSpy).toHaveBeenCalledWith(esbuildPlugin.buildDirPath); 28 | }); 29 | 30 | it('should not call chdir if the invoked function is not a node function', () => { 31 | const esbuildPlugin = { 32 | buildDirPath: 'workdir/.build', 33 | serverless: { 34 | config: {}, 35 | }, 36 | options: { 37 | function: 'hello', 38 | }, 39 | functions: {}, 40 | }; 41 | 42 | preLocal.call(esbuildPlugin as unknown as EsbuildServerlessPlugin); 43 | 44 | expect(chdirSpy).not.toHaveBeenCalled(); 45 | }); 46 | -------------------------------------------------------------------------------- /src/tests/type-predicate.test.ts: -------------------------------------------------------------------------------- 1 | import { isPackagerId } from '../type-predicate'; 2 | 3 | describe('isPackagerId()', () => { 4 | it('Returns true for valid input', () => { 5 | ['npm', 'pnpm', 'yarn'].forEach((id) => { 6 | expect(isPackagerId(id)).toBeTruthy(); 7 | }); 8 | }); 9 | 10 | it('Returns false for invalid input', () => { 11 | ['not-a-real-packager-id', false, 123, [], {}].forEach((id) => { 12 | expect(isPackagerId(id)).toBeFalsy(); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/tests/util.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import mockFs from 'mock-fs'; 3 | import path from 'path'; 4 | import extract from 'extract-zip'; 5 | import globby from 'globby'; 6 | import crypto from 'crypto'; 7 | 8 | import { findProjectRoot, zip } from '../utils'; 9 | 10 | describe('utils/findProjectRoot', () => { 11 | it('should properly Find a Project Root.', () => { 12 | /* Broken implementation in pack-externals we're trying to fix. */ 13 | const rootPackageJsonPath = path.join(findProjectRoot() || '', './package.json'); 14 | 15 | /* Looking up at project root relative to ./src/tests/ */ 16 | expect(rootPackageJsonPath).toEqual(path.join(__dirname, '../../package.json')); 17 | }); 18 | }); 19 | 20 | describe('utils/zip', () => { 21 | beforeEach(() => { 22 | mockFs({ 23 | '/src': { 24 | 'test.txt': 'lorem ipsum', 25 | modules: { 26 | 'module.txt': 'lorem ipsum 2', 27 | }, 28 | }, 29 | '/dist': {}, 30 | }); 31 | }); 32 | 33 | afterEach(() => { 34 | mockFs.restore(); 35 | }); 36 | 37 | it('should fail with.', async () => { 38 | const source = '/src'; 39 | const destination = '/dist'; 40 | const zipPath = path.join(destination, 'archive.zip'); 41 | const filesPathList = [ 42 | { 43 | rootPath: path.join(source, 'incorrect.txt'), 44 | localPath: 'test.txt', 45 | }, 46 | ]; 47 | 48 | await expect(zip(zipPath, filesPathList)).rejects.toThrow("ENOENT, no such file or directory '/src/incorrect.txt'"); 49 | }); 50 | 51 | it.each([{ useNativeZip: true }, { useNativeZip: false }])( 52 | 'should properly archive files when useNativeZip=$useNativeZip.', 53 | async ({ useNativeZip }) => { 54 | const source = '/src'; 55 | const destination = '/dist'; 56 | const zipPath = path.join(destination, 'archive.zip'); 57 | const filesPathList = [ 58 | { 59 | rootPath: path.join(source, 'test.txt'), 60 | localPath: 'test.txt', 61 | }, 62 | { 63 | rootPath: path.join(source, 'modules', 'module.txt'), 64 | localPath: 'modules/module.txt', 65 | }, 66 | ]; 67 | 68 | await zip(zipPath, filesPathList, useNativeZip); 69 | 70 | expect(fs.existsSync(zipPath)).toEqual(true); 71 | 72 | await extract(zipPath, { dir: destination }); 73 | 74 | const files = await globby(['**/*'], { cwd: destination, dot: true }); 75 | 76 | expect(files).toEqual(['archive.zip', 'test.txt', 'modules/module.txt']); 77 | 78 | // native zip is not deterministic 79 | if (!useNativeZip) { 80 | const data = fs.readFileSync(zipPath); 81 | const fileHash = crypto.createHash('sha256').update(data).digest('base64'); 82 | expect(fileHash).toEqual('iCZdyHJ7ON2LLwBIE6gQmRvBTzXBogSqJTMvHSenzGk='); 83 | } 84 | } 85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /src/type-predicate.ts: -------------------------------------------------------------------------------- 1 | import type { PackagerId } from './types'; 2 | 3 | export function isPackagerId(input: unknown): input is PackagerId { 4 | return input === 'npm' || input === 'pnpm' || input === 'yarn'; 5 | } 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { WatchOptions } from 'chokidar'; 2 | import type { BuildOptions, BuildResult, Plugin } from 'esbuild'; 3 | import type Serverless from 'serverless'; 4 | 5 | export type ConfigFn = (sls: Serverless) => Configuration; 6 | 7 | export type Plugins = Plugin[]; 8 | export type ReturnPluginsFn = (sls: Serverless) => Plugins; 9 | export type ESMPluginsModule = { default: Plugins | ReturnPluginsFn }; 10 | 11 | export interface ImprovedServerlessOptions extends Serverless.Options { 12 | package?: string; 13 | } 14 | 15 | export interface WatchConfiguration { 16 | pattern?: string[] | string; 17 | ignore?: string[] | string; 18 | chokidar?: WatchOptions; 19 | } 20 | 21 | export interface PackagerOptions { 22 | scripts?: string[] | string; 23 | noInstall?: boolean; 24 | ignoreLockfile?: boolean; 25 | } 26 | 27 | interface NodeExternalsOptions { 28 | allowList?: string[]; 29 | } 30 | 31 | export type EsbuildOptions = Omit; 32 | 33 | export interface Configuration extends EsbuildOptions { 34 | concurrency?: number; 35 | zipConcurrency?: number; 36 | packager: PackagerId; 37 | packagerOptions: PackagerOptions; 38 | packagePath: string; 39 | exclude: '*' | string[]; 40 | nativeZip: boolean; 41 | watch: WatchConfiguration; 42 | installExtraArgs: string[]; 43 | plugins?: string | Plugin[]; 44 | keepOutputDirectory?: boolean; 45 | outputWorkFolder?: string; 46 | outputBuildFolder?: string; 47 | outputFileExtension: '.js' | '.cjs' | '.mjs'; 48 | nodeExternals?: NodeExternalsOptions; 49 | skipBuild?: boolean; 50 | skipRebuild?: boolean; 51 | skipBuildExcludeFns: string[]; 52 | stripEntryResolveExtensions?: boolean; 53 | disposeContext?: boolean; 54 | } 55 | 56 | export interface EsbuildFunctionDefinitionHandler extends Serverless.FunctionDefinitionHandler { 57 | disposeContext?: boolean; 58 | skipEsbuild: boolean; 59 | esbuildEntrypoint?: string; 60 | } 61 | 62 | export interface FunctionEntry { 63 | entry: string; 64 | func: Serverless.FunctionDefinitionHandler | null; 65 | functionAlias?: string; 66 | } 67 | 68 | export interface FunctionBuildResult extends FunctionReference { 69 | bundlePath: string; 70 | } 71 | 72 | export interface FunctionReference { 73 | func: Serverless.FunctionDefinitionHandler; 74 | functionAlias: string; 75 | } 76 | 77 | interface BuildInvalidate { 78 | (): Promise; 79 | dispose(): void; 80 | } 81 | 82 | interface BuildIncremental extends BuildResult { 83 | rebuild: BuildInvalidate; 84 | } 85 | 86 | interface OldAPIResult extends BuildResult { 87 | rebuild?: BuildInvalidate; 88 | stop?: () => void; 89 | } 90 | 91 | export interface FileBuildResult { 92 | bundlePath: string; 93 | entry: string; 94 | result: OldAPIResult; 95 | context?: BuildContext | null; 96 | } 97 | 98 | interface ServeOptions { 99 | port?: number; 100 | host?: string; 101 | servedir?: string; 102 | keyfile?: string; 103 | certfile?: string; 104 | onRequest?: (args: ServeOnRequestArgs) => void; 105 | } 106 | 107 | interface ServeOnRequestArgs { 108 | remoteAddress: string; 109 | method: string; 110 | path: string; 111 | status: number; 112 | /** The time to generate the response, not to send it */ 113 | timeInMS: number; 114 | } 115 | 116 | export interface BuildContext { 117 | /** Documentation: https://esbuild.github.io/api/#rebuild */ 118 | rebuild(): Promise; 119 | 120 | /** Documentation: https://esbuild.github.io/api/#watch */ 121 | watch(options?: {}): Promise; 122 | 123 | /** Documentation: https://esbuild.github.io/api/#serve */ 124 | serve(options?: ServeOptions): Promise; 125 | 126 | cancel(): Promise; 127 | dispose(): Promise; 128 | } 129 | 130 | /** Documentation: https://esbuild.github.io/api/#serve-return-values */ 131 | interface ServeResult { 132 | port: number; 133 | hosts: string[]; 134 | } 135 | 136 | export type JSONObject = any; 137 | 138 | export interface DependenciesResult { 139 | stdout?: string; 140 | dependencies?: DependencyMap; 141 | } 142 | 143 | export type DependencyMap = Record; 144 | 145 | export interface DependencyTree { 146 | version: string; 147 | dependencies?: DependencyMap; 148 | /** Indicates the dependency is available from the root node_modules folder/root of this tree */ 149 | isRootDep?: boolean; 150 | } 151 | 152 | export interface IFile { 153 | readonly localPath: string; 154 | readonly rootPath: string; 155 | } 156 | export type IFiles = readonly IFile[]; 157 | 158 | export type PackagerId = 'npm' | 'pnpm' | 'yarn'; 159 | 160 | export type PackageJSON = { 161 | name: string; 162 | version: string; 163 | dependencies?: Record; 164 | devDependencies?: Record; 165 | peerDependencies?: Record; 166 | [key: string]: unknown; 167 | }; 168 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { FileSystem } from '@effect/platform'; 2 | import { NodeFileSystem } from '@effect/platform-node'; 3 | import archiver from 'archiver'; 4 | import { bestzip } from 'bestzip'; 5 | import { type Cause, Effect, Option } from 'effect'; 6 | import execa from 'execa'; 7 | import fs from 'fs-extra'; 8 | import path from 'path'; 9 | import type { ESMPluginsModule, IFile, IFiles } from './types'; 10 | import FS, { FSyncLayer, makePath, makeTempPathScoped, safeFileExists } from './utils/effect-fs'; 11 | 12 | export class SpawnError extends Error { 13 | constructor(message: string, public stdout: string, public stderr: string) { 14 | super(message); 15 | } 16 | 17 | toString() { 18 | return `${this.message}\n${this.stderr}`; 19 | } 20 | } 21 | 22 | /** 23 | * Executes a child process without limitations on stdout and stderr. 24 | * On error (exit code is not 0), it rejects with a SpawnProcessError that contains the stdout and stderr streams, 25 | * on success it returns the streams in an object. 26 | * @param {string} command - Command 27 | * @param {string[]} [args] - Arguments 28 | * @param {Object} [options] - Options for child_process.spawn 29 | */ 30 | export function spawnProcess(command: string, args: string[], options: execa.Options) { 31 | return execa(command, args, options); 32 | } 33 | 34 | const rootOf = (p: string) => path.parse(path.resolve(p)).root; 35 | const isPathRoot = (p: string) => rootOf(p) === path.resolve(p); 36 | const findUpEffect = ( 37 | names: string[], 38 | directory = process.cwd() 39 | ): Effect.Effect => { 40 | const dir = path.resolve(directory); 41 | return Effect.all(names.map((name) => safeFileExists(path.join(dir, name)))).pipe( 42 | Effect.flatMap((exist) => { 43 | if (exist.some(Boolean)) return Option.some(dir); 44 | if (isPathRoot(dir)) return Option.none(); 45 | return findUpEffect(names, path.dirname(dir)); 46 | }) 47 | ); 48 | }; 49 | 50 | /** 51 | * Find a file by walking up parent directories 52 | */ 53 | export const findUp = (name: string) => 54 | findUpEffect([name]).pipe( 55 | Effect.orElseSucceed(() => undefined), 56 | Effect.provide(FSyncLayer), 57 | Effect.runSync 58 | ); 59 | 60 | /** 61 | * Forwards `rootDir` or finds project root folder. 62 | */ 63 | export const findProjectRoot = (rootDir?: string) => 64 | Effect.fromNullable(rootDir).pipe( 65 | Effect.orElse(() => findUpEffect(['yarn.lock', 'pnpm-lock.yaml', 'package-lock.json'])), 66 | Effect.orElseSucceed(() => undefined), 67 | Effect.provide(FSyncLayer), 68 | Effect.runSync 69 | ); 70 | 71 | export const humanSize = (size: number) => { 72 | const exponent = Math.floor(Math.log(size) / Math.log(1024)); 73 | const sanitized = (size / 1024 ** exponent).toFixed(2); 74 | 75 | return `${sanitized} ${['B', 'KB', 'MB', 'GB', 'TB'][exponent]}`; 76 | }; 77 | 78 | export const zip = async (zipPath: string, filesPathList: IFiles, useNativeZip = false): Promise => { 79 | // create a temporary directory to hold the final zip structure 80 | const tempDirName = `${path.basename(zipPath, path.extname(zipPath))}-${Date.now().toString()}`; 81 | 82 | const copyFileEffect = (temp: string) => (file: IFile) => FS.copy(file.rootPath, path.join(temp, file.localPath)); 83 | const bestZipEffect = (temp: string) => 84 | Effect.tryPromise(() => bestzip({ source: '*', destination: zipPath, cwd: temp })); 85 | const nodeZipEffect = Effect.tryPromise(() => nodeZip(zipPath, filesPathList)); 86 | 87 | const archiveEffect = makeTempPathScoped(tempDirName).pipe( 88 | // copy all required files from origin path to (sometimes modified) target path 89 | Effect.tap((temp) => Effect.all(filesPathList.map(copyFileEffect(temp)), { discard: true })), 90 | // prepare zip folder 91 | Effect.tap(() => makePath(path.dirname(zipPath))), 92 | // zip the temporary directory 93 | Effect.andThen((temp) => (useNativeZip ? bestZipEffect(temp) : nodeZipEffect)), 94 | Effect.scoped 95 | ); 96 | 97 | await archiveEffect.pipe(Effect.provide(NodeFileSystem.layer), Effect.runPromise); 98 | }; 99 | 100 | function nodeZip(zipPath: string, filesPathList: IFiles): Promise { 101 | const zipArchive = archiver.create('zip'); 102 | const output = fs.createWriteStream(zipPath); 103 | 104 | // write zip 105 | output.on('open', () => { 106 | zipArchive.pipe(output); 107 | 108 | filesPathList.forEach((file) => { 109 | const stats = fs.statSync(file.rootPath); 110 | if (stats.isDirectory()) return; 111 | 112 | zipArchive.append(fs.readFileSync(file.rootPath), { 113 | name: file.localPath, 114 | mode: stats.mode, 115 | date: new Date(0), // necessary to get the same hash when zipping the same content 116 | }); 117 | }); 118 | 119 | zipArchive.finalize(); 120 | }); 121 | 122 | return new Promise((resolve, reject) => { 123 | output.on('close', resolve); 124 | zipArchive.on('error', (err) => reject(err)); 125 | }); 126 | } 127 | 128 | export function trimExtension(entry: string) { 129 | return entry.slice(0, -path.extname(entry).length); 130 | } 131 | 132 | export const isEmpty = (obj: Record) => { 133 | // eslint-disable-next-line no-unreachable-loop 134 | for (const _i in obj) return false; 135 | 136 | return true; 137 | }; 138 | 139 | export const isESMModule = (obj: unknown): obj is ESMPluginsModule => { 140 | return typeof obj === 'object' && obj !== null && 'default' in obj; 141 | }; 142 | -------------------------------------------------------------------------------- /src/utils/effect-fs.ts: -------------------------------------------------------------------------------- 1 | import { FileSystem, Error as PlatformError } from '@effect/platform'; 2 | import { Effect } from 'effect'; 3 | import fs from 'fs-extra'; 4 | import os from 'node:os'; 5 | import path from 'node:path'; 6 | 7 | const FS = Effect.serviceFunctions(FileSystem.FileSystem); 8 | 9 | export const makePath = (p: string) => FS.makeDirectory(p, { recursive: true }); 10 | export const safeFileExists = (p: string) => FS.exists(p).pipe(Effect.orElseSucceed(() => false)); 11 | export const safeFileRemove = (p: string) => FS.remove(p).pipe(Effect.orElse(() => Effect.void)); 12 | export const makeTempPathScoped = (dirName: string) => 13 | Effect.acquireRelease(Effect.succeed(path.join(os.tmpdir(), dirName)).pipe(Effect.tap(makePath)), safeFileRemove); 14 | 15 | export const FSyncLayer = FileSystem.layerNoop({ 16 | exists: (p) => 17 | Effect.try({ 18 | try: () => fs.existsSync(p), 19 | catch: (error) => 20 | PlatformError.SystemError({ 21 | module: 'FileSystem', 22 | reason: 'Unknown', 23 | method: 'exists', 24 | pathOrDescriptor: p, 25 | message: (error as Error).message, 26 | }), 27 | }), 28 | }); 29 | 30 | export default FS; 31 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "noEmit": false, 6 | "outDir": "dist", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules", "src/tests"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "rootDirs": ["e2e"], 9 | "allowJs": true, 10 | "noEmit": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "allowSyntheticDefaultImports": true, 14 | "isolatedModules": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "allowUnusedLabels": false, 17 | "allowUnreachableCode": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noErrorTruncation": true, 21 | "noUncheckedIndexedAccess": true 22 | }, 23 | "include": ["./*.js", "src", "e2e", "examples"], 24 | "exclude": ["node_modules"] 25 | } 26 | --------------------------------------------------------------------------------