From 6172b653da523e7c4ebbb1001f4622e09a1e7d28 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 27 Dec 2022 06:11:04 -0600 Subject: [PATCH] chore: `tsconfig.json` updates (#408) * chore(deps): updated dependencies * chore: updated TS config - updated `target` to match `package.json` `engines.node` - enabled `strict` * chore: added types for `bestzip` * chore: `strict` updates * test: updated snapshots * chore: removed commented code --- e2e/__snapshots__/complete.test.ts.snap | 2 +- e2e/__snapshots__/individually.test.ts.snap | 8 +- e2e/__snapshots__/minimal.test.ts.snap | 5 +- package.json | 6 +- src/bundle.ts | 112 ++++++++------ src/declarations.d.ts | 9 ++ src/helper.ts | 90 +++++++++-- src/index.ts | 158 ++++++++++++-------- src/pack-externals.ts | 111 ++++++++++---- src/pack.ts | 58 ++++--- src/packagers/npm.ts | 35 +++-- src/packagers/packager.ts | 4 +- src/packagers/pnpm.ts | 22 +-- src/packagers/yarn.ts | 37 +++-- src/pre-local.ts | 8 +- src/pre-offline.ts | 6 +- src/tests/index.test.ts | 10 +- src/tests/pack.test.ts | 3 +- src/tests/packagers/npm.test.ts | 6 +- src/tests/packagers/yarn.test.ts | 2 +- src/tests/pre-local.test.ts | 10 +- src/types.ts | 15 +- src/utils.ts | 2 +- tsconfig.build.json | 13 ++ tsconfig.json | 21 ++- 25 files changed, 519 insertions(+), 234 deletions(-) create mode 100644 src/declarations.d.ts create mode 100644 tsconfig.build.json diff --git a/e2e/__snapshots__/complete.test.ts.snap b/e2e/__snapshots__/complete.test.ts.snap index 55a5a54b..9e56ab2f 100644 --- a/e2e/__snapshots__/complete.test.ts.snap +++ b/e2e/__snapshots__/complete.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`complete 1`] = ` -"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}); +""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}); " `; diff --git a/e2e/__snapshots__/individually.test.ts.snap b/e2e/__snapshots__/individually.test.ts.snap index a22b95a8..c657edfd 100644 --- a/e2e/__snapshots__/individually.test.ts.snap +++ b/e2e/__snapshots__/individually.test.ts.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`individually 1`] = ` -"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}); +""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}); " `; exports[`individually 2`] = ` -"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}); +""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}); " `; @@ -267,7 +267,7 @@ exports[`individually 5`] = ` }, "Type": "AWS::Lambda::Permission", }, - "Hello1LambdaVersionp3wHp8JuRi4SmdELmNVzjQvueXFr127Qn2aIb5O9YHU": { + "Hello1LambdaVersionCSwh1ndXXWK0PuziFcAZEvHblwz5E8ZASq6DpWhA": { "DeletionPolicy": "Retain", "Properties": { "CodeSha256": Any, @@ -345,7 +345,7 @@ exports[`individually 5`] = ` }, "Type": "AWS::Lambda::Permission", }, - "Hello2LambdaVersionongx6o4TBoQbh3kyG4cSG0Bj7rT3CNaL1NNKJ5gGiI": { + "Hello2LambdaVersionwMMe1vFJ1KSlA5MihlkJVM5uJp72R4R6u5Vtbnz3NSY": { "DeletionPolicy": "Retain", "Properties": { "CodeSha256": Any, diff --git a/e2e/__snapshots__/minimal.test.ts.snap b/e2e/__snapshots__/minimal.test.ts.snap index 35136a9b..ae273ed1 100644 --- a/e2e/__snapshots__/minimal.test.ts.snap +++ b/e2e/__snapshots__/minimal.test.ts.snap @@ -1,7 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`minimal 1`] = ` -"var __getOwnPropNames = Object.getOwnPropertyNames; +""use strict"; +var __getOwnPropNames = Object.getOwnPropertyNames; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; @@ -14265,7 +14266,7 @@ exports[`minimal 4`] = ` }, "Type": "AWS::Lambda::Permission", }, - "ValidateIsinLambdaVersionGmMQZiArm4DPeKSFh1p52LaTpYXcVfaCai5ZLRw": { + "ValidateIsinLambdaVersionEpft2h1OOZyD03CU8JMaqcj3Xcth3KeWrSuKw8G2UQ": { "DeletionPolicy": "Retain", "Properties": { "CodeSha256": Any, diff --git a/package.json b/package.json index 5102494a..5dbde6b1 100644 --- a/package.json +++ b/package.json @@ -29,15 +29,13 @@ }, "main": "dist/index.js", "files": [ - "dist", - "package.json", - "README.md" + "dist" ], "scripts": { "prepublishOnly": "npm run build", "precommit": "npm run test", "prebuild": "npm run clean", - "build": "tsc", + "build": "tsc -p ./tsconfig.build.json", "dev": "npm run build -- --watch", "typecheck": "tsc --noEmit", "clean": "rm -rf ./dist", diff --git a/src/bundle.ts b/src/bundle.ts index 9f04e667..34031ed2 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -4,31 +4,68 @@ import pMap from 'p-map'; import path from 'path'; import { uniq } from 'ramda'; -import EsbuildServerlessPlugin from '.'; -import { isESM } from './helper'; -import { FileBuildResult } from './types'; +import type EsbuildServerlessPlugin from './index'; +import { asArray, assertIsString, isESM, isString } from './helper'; +import type { EsbuildOptions, FileBuildResult } from './types'; import { trimExtension } from './utils'; +import assert from 'assert'; + +import type { FunctionBuildResult } from './types'; + +const getStringArray = (input: unknown): string[] => asArray(input).filter(isString); export async function bundle(this: EsbuildServerlessPlugin, incremental = false): Promise { + assert(this.buildOptions, 'buildOptions is not defined'); + this.prepare(); - this.log.verbose(`Compiling to ${this.buildOptions.target} bundle with esbuild...`); - if (this.buildOptions.disableIncremental === true) { + + this.log.verbose(`Compiling to ${this.buildOptions?.target} bundle with esbuild...`); + + if (this.buildOptions?.disableIncremental === true) { incremental = false; } + const exclude = getStringArray(this.buildOptions?.exclude); + + // esbuild v0.7.0 introduced config options validation, so I have to delete plugin specific options from esbuild config. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const esbuildOptions: EsbuildOptions = [ + 'concurrency', + 'exclude', + 'nativeZip', + 'packager', + 'packagePath', + 'watch', + 'keepOutputDirectory', + 'packagerOptions', + 'installExtraArgs', + 'disableIncremental', + 'outputFileExtension', + 'outputBuildFolder', + 'outputWorkFolder', + 'nodeExternals', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ].reduce>((options, optionName) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [optionName]: _, ...rest } = options; + + return rest; + }, this.buildOptions); + const config: Omit = { - ...this.buildOptions, - external: [ - ...this.buildOptions.external, - ...(this.buildOptions.exclude === '*' || this.buildOptions.exclude.includes('*') - ? [] - : this.buildOptions.exclude), - ], + ...esbuildOptions, + external: [...getStringArray(this.buildOptions?.external), ...(exclude.includes('*') ? [] : exclude)], incremental, plugins: this.plugins, }; - if (isESM(this.buildOptions) && this.buildOptions.outputFileExtension === '.cjs') { + const { buildOptions, buildDirPath } = this; + + assert(buildOptions, 'buildOptions is not defined'); + + assertIsString(buildDirPath, 'buildDirPath is not a string'); + + if (isESM(buildOptions) && buildOptions.outputFileExtension === '.cjs') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Serverless typings (as of v3.0.2) are incorrect throw new this.serverless.classes.Error( @@ -36,39 +73,23 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false) ); } - if (!isESM(this.buildOptions) && this.buildOptions.outputFileExtension === '.mjs') { + if (!isESM(buildOptions) && buildOptions.outputFileExtension === '.mjs') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Serverless typings (as of v3.0.2) are incorrect throw new this.serverless.classes.Error('ERROR: Non esm builds should not output a file with extension ".mjs".'); } - if (this.buildOptions.outputFileExtension !== '.js') { - config.outExtension = { '.js': this.buildOptions.outputFileExtension }; + if (buildOptions.outputFileExtension !== '.js') { + config.outExtension = { '.js': buildOptions.outputFileExtension }; } - // esbuild v0.7.0 introduced config options validation, so I have to delete plugin specific options from esbuild config. - delete config['concurrency']; - delete config['exclude']; - delete config['nativeZip']; - delete config['packager']; - delete config['packagePath']; - delete config['watch']; - delete config['keepOutputDirectory']; - delete config['packagerOptions']; - delete config['installExtraArgs']; - delete config['disableIncremental']; - delete config['outputFileExtension']; - delete config['outputBuildFolder']; - delete config['outputWorkFolder']; - delete config['nodeExternals']; - /** Build the files */ const bundleMapper = async (entry: string): Promise => { - const bundlePath = entry.slice(0, entry.lastIndexOf('.')) + this.buildOptions.outputFileExtension; + const bundlePath = entry.slice(0, entry.lastIndexOf('.')) + buildOptions.outputFileExtension; // check cache if (this.buildCache) { - const { result } = this.buildCache[entry]; + const { result } = this.buildCache[entry] ?? {}; if (result?.rebuild) { await result.rebuild(); return { bundlePath, entry, result }; @@ -78,12 +99,12 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false) const result = await build({ ...config, entryPoints: [entry], - outdir: path.join(this.buildDirPath, path.dirname(entry)), + outdir: path.join(buildDirPath, path.dirname(entry)), }); if (config.metafile) { fs.writeFileSync( - path.join(this.buildDirPath, `${trimExtension(entry)}-meta.json`), + path.join(buildDirPath, `${trimExtension(entry)}-meta.json`), JSON.stringify(result.metafile, null, 2) ); } @@ -93,10 +114,10 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false) // Files can contain multiple handlers for multiple functions, we want to get only the unique ones const uniqueFiles: string[] = uniq(this.functionEntries.map(({ entry }) => entry)); - this.log.verbose(`Compiling with concurrency: ${this.buildOptions.concurrency}`); + this.log.verbose(`Compiling with concurrency: ${buildOptions.concurrency}`); const fileBuildResults = await pMap(uniqueFiles, bundleMapper, { - concurrency: this.buildOptions.concurrency, + concurrency: buildOptions.concurrency, }); // Create a cache with entry as key @@ -106,10 +127,17 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false) }, {}); // Map function entries back to bundles - this.buildResults = this.functionEntries.map(({ entry, func, functionAlias }) => { - const { bundlePath } = this.buildCache[entry]; - return { bundlePath, func, functionAlias }; - }); + this.buildResults = this.functionEntries + .map(({ entry, func, functionAlias }) => { + const { bundlePath } = this.buildCache[entry] ?? {}; + + if (typeof bundlePath !== 'string' || func === null) { + return; + } + + return { bundlePath, func, functionAlias }; + }) + .filter((result): result is FunctionBuildResult => typeof result === 'object'); this.log.verbose('Compiling completed.'); } diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 00000000..dcc37986 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,9 @@ +declare module 'bestzip' { + export type BestZipOptions = { + source: string; + destination: string; + cwd: string; + }; + + export function bestzip(options: BestZipOptions): Promise; +} diff --git a/src/helper.ts b/src/helper.ts index 6db86bf9..841a0b0c 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,3 +1,4 @@ +import assert, { AssertionError } from 'assert'; import os from 'os'; import path from 'path'; @@ -10,10 +11,22 @@ import type Serverless from 'serverless'; import type ServerlessPlugin from 'serverless/classes/Plugin'; import type { Configuration, DependencyMap, FunctionEntry } from './types'; +export function asArray(data: T | T[]): T[] { + return Array.isArray(data) ? data : [data]; +} + +export const isString = (input: unknown): input is string => typeof input === 'string'; + +export function assertIsString(input: unknown, message = 'input is not a string'): asserts input is string { + if (!isString(input)) { + throw new AssertionError({ actual: input, message }); + } +} + export function extractFunctionEntries( cwd: string, provider: string, - functions?: Record + functions: Record ): FunctionEntry[] { // The Google provider will use the entrypoint not from the definition of the // handler function, but instead from the package.json:main field, or via a @@ -23,6 +36,7 @@ export function extractFunctionEntries( // it instead selects the index.js file. if (provider === 'google') { const packageFilePath = path.join(cwd, 'package.json'); + if (fs.existsSync(packageFilePath)) { // Load in the package.json file. const packageFile = JSON.parse(fs.readFileSync(packageFilePath).toString()); @@ -34,6 +48,7 @@ export function extractFunctionEntries( // Check that the file indeed exists. if (!fs.existsSync(path.join(cwd, entry))) { console.log(`Cannot locate entrypoint, ${entry} not found`); + throw new Error('Compilation failed'); } @@ -43,6 +58,9 @@ export function extractFunctionEntries( return Object.keys(functions).map((functionAlias) => { const func = functions[functionAlias]; + + assert(func, `${functionAlias} not found in functions`); + const h = func.handler; const fnName = path.extname(h); const fnNameLastAppearanceIndex = h.lastIndexOf(fnName); @@ -64,6 +82,7 @@ export function extractFunctionEntries( // Can't find the files. Watch will have an exception anyway. So throw one with error. console.log(`Cannot locate handler - ${fileName} not found`); + throw new Error( 'Compilation failed. Please ensure you have an index file with ext .ts or .js, or have a path listed as main key in package.json' ); @@ -88,14 +107,22 @@ export const flatDep = (root: DependencyMap, rootDepsFilter: string[]): string[] Object.entries(deps).forEach(([depName, details]) => { // only for root level dependencies - if (filter && !filter.includes(depName)) return; + if (filter && !filter.includes(depName)) { + return; + } if (details.isRootDep || filter) { // We already have this root dep and it's dependencies - skip this iteration - if (flattenedDependencies.has(depName)) return; + if (flattenedDependencies.has(depName)) { + return; + } flattenedDependencies.add(depName); - recursiveFind(root[depName].dependencies); + + const dep = root[depName]; + + dep && recursiveFind(dep.dependencies); + return; } @@ -117,7 +144,13 @@ export const flatDep = (root: DependencyMap, rootDepsFilter: string[]): string[] * @example getBaseDep('package') returns 'package' * @param path */ -const getBaseDep = (path: string): string => /^@[^/]+\/[^/\n]+|^[^/\n]+/.exec(path)[0]; +const getBaseDep = (path: string): string | undefined => { + const result = /^@[^/]+\/[^/\n]+|^[^/\n]+/.exec(path); + + if (Array.isArray(result) && result[0]) { + return result[0]; + } +}; export const isESM = (buildOptions: Configuration): boolean => { return buildOptions.format === 'esm' || (buildOptions.platform === 'neutral' && !buildOptions.format); @@ -156,25 +189,50 @@ export const getDepsFromBundle = (bundlePath: string, useESM: boolean): string[] }, }); - return uniq(deps.map((dep) => getBaseDep(dep))); + const baseDeps = deps.map(getBaseDep).filter(isString); + + return uniq(baseDeps); }; -export const doSharePath = (child, parent) => { - if (child === parent) return true; +export const doSharePath = (child: string, parent: string): boolean => { + if (child === parent) { + return true; + } + const parentTokens = parent.split('/'); const childToken = child.split('/'); - return parentTokens.every((t, i) => childToken[i] === t); + + return parentTokens.every((token, index) => childToken[index] === token); +}; + +export type NodeProviderRuntimeMatcher = { + [Version in Versions as `nodejs${Version}.x`]: `node${Version}`; }; -export const providerRuntimeMatcher = Object.freeze({ - aws: { - 'nodejs18.x': 'node18', - 'nodejs16.x': 'node16', - 'nodejs14.x': 'node14', - 'nodejs12.x': 'node12', - }, +export type NodeMatcher = NodeProviderRuntimeMatcher<12 | 14 | 16 | 18>; + +export type NodeMatcherKey = keyof NodeMatcher; + +const nodeMatcher: NodeMatcher = { + 'nodejs18.x': 'node18', + 'nodejs16.x': 'node16', + 'nodejs14.x': 'node14', + 'nodejs12.x': 'node12', +}; + +export const providerRuntimeMatcher = Object.freeze>({ + aws: nodeMatcher, }); +export const isNodeMatcherKey = (input: unknown): input is NodeMatcherKey => + typeof input === 'string' && Object.keys(nodeMatcher).includes(input); + +export function assertIsSupportedRuntime(input: unknown): asserts input is NodeMatcherKey { + if (!isNodeMatcherKey(input)) { + throw new AssertionError({ actual: input, message: 'not a supported runtime' }); + } +} + export const buildServerlessV3LoggerFromLegacyLogger = ( legacyLogger: Serverless['cli'], verbose?: boolean diff --git a/src/index.ts b/src/index.ts index 4b4b3e9c..3324bfff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,32 @@ +import assert from 'assert'; +import path from 'path'; + import fs from 'fs-extra'; import globby from 'globby'; -import path from 'path'; + import { concat, mergeRight } from 'ramda'; -import Serverless from 'serverless'; -import ServerlessPlugin from 'serverless/classes/Plugin'; +import type Serverless from 'serverless'; +import type ServerlessPlugin from 'serverless/classes/Plugin'; import chokidar from 'chokidar'; import anymatch from 'anymatch'; -import { buildServerlessV3LoggerFromLegacyLogger, extractFunctionEntries, providerRuntimeMatcher } from './helper'; +import { + asArray, + assertIsString, + assertIsSupportedRuntime, + buildServerlessV3LoggerFromLegacyLogger, + extractFunctionEntries, + isNodeMatcherKey, + isString, + providerRuntimeMatcher, +} from './helper'; import { packExternalModules } from './pack-externals'; import { pack } from './pack'; import { preOffline } from './pre-offline'; import { preLocal } from './pre-local'; import { bundle } from './bundle'; import { BUILD_FOLDER, ONLY_PREFIX, SERVERLESS_FOLDER, WORK_FOLDER } from './constants'; -import { Configuration, FileBuildResult, FunctionBuildResult, Plugins, ReturnPluginsFn, ConfigFn } from './types'; +import type { Configuration, FileBuildResult, FunctionBuildResult, Plugins, ReturnPluginsFn, ConfigFn } from './types'; function updateFile(op: string, src: string, dest: string) { if (['add', 'change', 'addDir'].includes(op)) { @@ -34,24 +46,26 @@ function updateFile(op: string, src: string, dest: string) { class EsbuildServerlessPlugin implements ServerlessPlugin { serviceDirPath: string; - outputWorkFolder: string; - workDirPath: string; - outputBuildFolder: string; - buildDirPath: string; + outputWorkFolder: string | undefined; + workDirPath: string | undefined; + outputBuildFolder: string | undefined; + buildDirPath: string | undefined; log: ServerlessPlugin.Logging['log']; serverless: Serverless; options: Serverless.Options; hooks: ServerlessPlugin.Hooks; - buildOptions: Configuration; - buildResults: FunctionBuildResult[]; + buildOptions: Configuration | undefined; + buildResults: FunctionBuildResult[] | undefined; /** Used for storing previous esbuild build results so we can rebuild more efficiently */ - buildCache: Record; - bundle: (incremental?: boolean) => Promise; - packExternalModules: () => Promise; - pack: () => Promise; - preOffline: () => Promise; - preLocal: () => void; + buildCache: Record = {}; + + // These are bound to imported functions. + packExternalModules: typeof packExternalModules; + pack: typeof pack; + preOffline: typeof preOffline; + preLocal: typeof preLocal; + bundle: typeof bundle; constructor(serverless: Serverless, options: Serverless.Options, logging?: ServerlessPlugin.Logging) { this.serverless = serverless; @@ -60,6 +74,7 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore old versions use servicePath, new versions serviceDir. Types will use only one of them this.serviceDirPath = this.serverless.config.serviceDir || this.serverless.config.servicePath; + this.packExternalModules = packExternalModules.bind(this); this.pack = pack.bind(this); this.preOffline = preOffline.bind(this); @@ -116,7 +131,6 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { private init() { this.buildOptions = this.getBuildOptions(); - this.outputWorkFolder = this.buildOptions.outputWorkFolder || WORK_FOLDER; this.outputBuildFolder = this.buildOptions.outputBuildFolder || BUILD_FOLDER; this.workDirPath = path.join(this.serviceDirPath, this.outputWorkFolder); @@ -132,7 +146,8 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { private isNodeFunction(func: Serverless.FunctionDefinitionHandler): boolean { const runtime = func.runtime || this.serverless.service.provider.runtime; const runtimeMatcher = providerRuntimeMatcher[this.serverless.service.provider.name]; - return Boolean(runtimeMatcher?.[runtime]); + + return isNodeMatcherKey(runtime) && typeof runtimeMatcher?.[runtime] === 'string'; } /** @@ -164,16 +179,15 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } get plugins(): Plugins { - if (!this.buildOptions.plugins) return; + if (!this.buildOptions?.plugins) { + return []; + } if (Array.isArray(this.buildOptions.plugins)) { return this.buildOptions.plugins; } - const plugins: Plugins | ReturnPluginsFn = require(path.join( - this.serviceDirPath, - this.buildOptions.plugins as string - )); + const plugins: Plugins | ReturnPluginsFn = require(path.join(this.serviceDirPath, this.buildOptions.plugins)); if (typeof plugins === 'function') { return plugins(this.serverless); @@ -184,8 +198,8 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { get packagePatterns() { const { service } = this.serverless; - const patterns = []; - const ignored = []; + const patterns: string[] = []; + const ignored: string[] = []; for (const pattern of service.package.patterns) { if (pattern.startsWith('!')) { @@ -195,13 +209,10 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, fn] of Object.entries(this.functions)) { - if (fn.package.patterns.length === 0) { - continue; - } + for (const fn of Object.values(this.functions)) { + const patterns = asArray(fn.package?.patterns).filter(isString); - for (const pattern of fn.package.patterns) { + for (const pattern of patterns) { if (pattern.startsWith('!')) { ignored.push(pattern.slice(1)); } else { @@ -233,19 +244,23 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { outputFileExtension: '.js', }; + const providerRuntime = this.serverless.service.provider.runtime; + + assertIsSupportedRuntime(providerRuntime); + const runtimeMatcher = providerRuntimeMatcher[this.serverless.service.provider.name]; - const target = runtimeMatcher?.[this.serverless.service.provider.runtime]; + const target = isNodeMatcherKey(providerRuntime) ? runtimeMatcher?.[providerRuntime] : undefined; + const resolvedOptions = { ...(target ? { target } : {}), }; const withDefaultOptions = mergeRight(DEFAULT_BUILD_OPTIONS); const withResolvedOptions = mergeRight(withDefaultOptions(resolvedOptions)); - const configPath = this.serverless.service.custom?.esbuild?.config; - let config: ConfigFn; - if (configPath) { - config = require(path.join(this.serviceDirPath, configPath)); - } + const configPath: string | undefined = this.serverless.service.custom?.esbuild?.config; + + const config: ConfigFn | undefined = configPath ? require(path.join(this.serviceDirPath, configPath)) : undefined; + return withResolvedOptions( config ? config(this.serverless) : this.serverless.service.custom?.esbuild ?? {} ); @@ -256,26 +271,23 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } async watch(): Promise { - let defaultPatterns = this.buildOptions.watch.pattern; + assert(this.buildOptions, 'buildOptions is not defined'); + + const defaultPatterns = asArray(this.buildOptions.watch.pattern).filter(isString); const options = { - ignored: this.buildOptions.watch.ignore || [], + ignored: asArray(this.buildOptions.watch.ignore).filter(isString), awaitWriteFinish: true, ignoreInitial: true, }; - if (!Array.isArray(defaultPatterns)) { - defaultPatterns = [defaultPatterns]; - } + const { patterns, ignored } = this.packagePatterns; - if (!Array.isArray(options.ignored)) { - options.ignored = [options.ignored]; - } + const allPatterns: string[] = [...defaultPatterns, ...patterns]; - const { patterns, ignored } = this.packagePatterns; - defaultPatterns = [...defaultPatterns, ...patterns]; options.ignored = [...options.ignored, ...ignored]; - chokidar.watch(defaultPatterns, options).on('all', (eventName, srcPath) => + + chokidar.watch(allPatterns, options).on('all', (eventName, srcPath) => this.bundle(true) .then(() => this.updateFile(eventName, srcPath)) .then(() => this.log.verbose('Watching files for changes...')) @@ -284,6 +296,9 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } prepare() { + assertIsString(this.buildDirPath, 'buildDirPath is not a string'); + assertIsString(this.workDirPath, 'workDirPath is not a string'); + fs.mkdirpSync(this.buildDirPath); fs.mkdirpSync(path.join(this.workDirPath, SERVERLESS_FOLDER)); // exclude serverless-esbuild @@ -314,12 +329,16 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } async updateFile(op: string, filename: string) { + assertIsString(this.buildDirPath, 'buildDirPath is not a string'); + const { service } = this.serverless; + const patterns = asArray(service.package.patterns).filter(isString); + if ( - service.package.patterns.length > 0 && + patterns.length > 0 && anymatch( - service.package.patterns.filter((p) => !p.startsWith('!')), + patterns.filter((p) => !p.startsWith('!')), filename ) ) { @@ -329,13 +348,15 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } for (const [functionAlias, fn] of Object.entries(this.functions)) { - if (fn.package.patterns.length === 0) { + if (fn.package?.patterns?.length === 0) { continue; } if ( anymatch( - fn.package.patterns.filter((p) => !p.startsWith('!')), + asArray(fn.package?.patterns) + .filter(isString) + .filter((p) => !p.startsWith('!')), filename ) ) { @@ -348,26 +369,36 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { /** Link or copy extras such as node_modules or package.patterns definitions */ async copyExtras() { + assertIsString(this.buildDirPath, 'buildDirPath is not a string'); + const { service } = this.serverless; + const packagePatterns = asArray(service.package.patterns).filter(isString); + // include any "extras" from the "patterns" section - if (service.package.patterns.length > 0) { - const files = await globby(service.package.patterns); + if (packagePatterns.length) { + const files = await globby(packagePatterns); for (const filename of files) { const destFileName = path.resolve(path.join(this.buildDirPath, filename)); + updateFile('add', path.resolve(filename), destFileName); } } // include any "extras" from the individual function "patterns" section for (const [functionAlias, fn] of Object.entries(this.functions)) { - if (fn.package.patterns.length === 0) { + const patterns = asArray(fn.package?.patterns).filter(isString); + + if (!patterns.length) { continue; } - const files = await globby(fn.package.patterns); + + const files = await globby(patterns); + for (const filename of files) { const destFileName = path.resolve(path.join(this.buildDirPath, `${ONLY_PREFIX}${functionAlias}`, filename)); + updateFile('add', path.resolve(filename), destFileName); } } @@ -378,13 +409,17 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { * packaging preferences. */ async moveArtifacts(): Promise { + assertIsString(this.workDirPath, 'workDirPath is not a string'); + const { service } = this.serverless; await fs.copy(path.join(this.workDirPath, SERVERLESS_FOLDER), path.join(this.serviceDirPath, SERVERLESS_FOLDER)); - if (service.package.individually || this.options.function) { + if (service.package.individually === true || this.options.function) { Object.values(this.functions).forEach((func) => { - func.package.artifact = path.join(SERVERLESS_FOLDER, path.basename(func.package.artifact)); + if (func.package?.artifact) { + func.package.artifact = path.join(SERVERLESS_FOLDER, path.basename(func.package.artifact)); + } }); return; } @@ -394,8 +429,11 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { async cleanup(): Promise { await this.moveArtifacts(); + // Remove temp build folder - if (!this.buildOptions.keepOutputDirectory) { + if (!this.buildOptions?.keepOutputDirectory) { + assertIsString(this.workDirPath, 'workDirPath is not a string'); + fs.removeSync(path.join(this.workDirPath)); } } diff --git a/src/pack-externals.ts b/src/pack-externals.ts index 9bc0f8b3..13b584c4 100644 --- a/src/pack-externals.ts +++ b/src/pack-externals.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import path from 'path'; import fse from 'fs-extra'; @@ -34,7 +35,8 @@ import type { } from 'esbuild-node-externals/dist/utils'; import type EsbuildServerlessPlugin from './index'; -import type { JSONObject } from './types'; +import type { JSONObject, PackageJSON } from './types'; +import { assertIsString } from './helper'; function rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { if (/^(?:file:[^/]{2}|\.\/|\.\.\/)/.test(moduleVersion)) { @@ -56,11 +58,17 @@ function addModulesToPackageJson(externalModules: string[], packageJson: JSONObj splitModule.splice(0, 1); splitModule[0] = '@' + splitModule[0]; } - let moduleVersion = join('@', tail(splitModule)); + + const dependencyName = head(splitModule); + + if (!dependencyName) { + return; + } + // We have to rebase file references to the target package.json - moduleVersion = rebaseFileReferences(pathToPackageRoot, moduleVersion); + const moduleVersion = rebaseFileReferences(pathToPackageRoot, join('@', tail(splitModule))); packageJson.dependencies = packageJson.dependencies || {}; - packageJson.dependencies[head(splitModule)] = moduleVersion; + packageJson.dependencies[dependencyName] = moduleVersion; }, externalModules); } @@ -68,19 +76,28 @@ function addModulesToPackageJson(externalModules: string[], packageJson: JSONObj * Resolve the needed versions of production dependencies for external modules. * @this - The active plugin instance */ -function getProdModules(externalModules: { external: string }[], packageJsonPath: string, rootPackageJsonPath: string) { - const packageJson = this.serverless.utils.readFileSync(packageJsonPath); - const prodModules = []; +function getProdModules( + this: EsbuildServerlessPlugin, + externalModules: { external: string }[], + packageJsonPath: string, + rootPackageJsonPath: string +) { + const packageJson = this.serverless.utils.readFileSync(packageJsonPath) as PackageJSON; // only process the module stated in dependencies section if (!packageJson.dependencies) { return []; } + const prodModules: string[] = []; + // Get versions of all transient modules forEach((externalModule) => { // (1) If not present in Dev Dependencies or Dependencies - if (!packageJson.dependencies[externalModule.external] && !packageJson.devDependencies[externalModule.external]) { + if ( + !packageJson.dependencies?.[externalModule.external] && + !packageJson.devDependencies?.[externalModule.external] + ) { this.log.debug( `INFO: Runtime dependency '${externalModule.external}' not found in dependencies or devDependencies. It has been excluded automatically.` ); @@ -89,7 +106,10 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath } // (2) If present in Dev Dependencies - if (!packageJson.dependencies[externalModule.external] && packageJson.devDependencies[externalModule.external]) { + if ( + !packageJson.dependencies?.[externalModule.external] && + packageJson.devDependencies?.[externalModule.external] + ) { // To minimize the chance of breaking setups we whitelist packages available on AWS here. These are due to the previously missing check // most likely set in devDependencies and should not lead to an error now. const ignoredDevDependencies = ['aws-sdk']; @@ -97,6 +117,9 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath if (!includes(externalModule.external, ignoredDevDependencies)) { // Runtime dependency found in devDependencies but not forcefully excluded this.log.error(`ERROR: Runtime dependency '${externalModule.external}' found in devDependencies.`); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Serverless typings (as of v3.0.2) are incorrect throw new this.serverless.classes.Error(`Serverless-webpack dependency error: ${externalModule.external}.`); } @@ -106,6 +129,7 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath return; } + // (3) otherwise let's get the version // get module package - either from root or local node_modules - will be used for version and peer deps @@ -115,6 +139,7 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath externalModule.external, 'package.json' ); + const localModulePackagePath = path.join( process.cwd(), path.dirname(packageJsonPath), @@ -122,19 +147,20 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath externalModule.external, 'package.json' ); + const modulePackagePath = fse.pathExistsSync(localModulePackagePath) ? localModulePackagePath : fse.pathExistsSync(rootModulePackagePath) ? rootModulePackagePath : null; - const modulePackage = modulePackagePath ? require(modulePackagePath) : {}; + + const modulePackage: Partial = modulePackagePath ? require(modulePackagePath) : {}; // Get version - const moduleVersion = packageJson.dependencies[externalModule.external] || modulePackage.version; + const moduleVersion = packageJson.dependencies?.[externalModule.external] || modulePackage.version; // add dep with version if we have it - versionless otherwise - if (moduleVersion) prodModules.push(`${externalModule.external}@${moduleVersion}`); - else prodModules.push(externalModule.external); + prodModules.push(moduleVersion ? `${externalModule.external}@${moduleVersion}` : externalModule.external); // Check if the module has any peer dependencies and include them too try { @@ -143,6 +169,9 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath const optionalPeerDependencies = Object.keys( pickBy((val) => val.optional, modulePackage.peerDependenciesMeta || {}) ); + + assert(this.buildOptions, 'buildOptions not defined'); + const peerDependenciesWithoutOptionals = omit( [...optionalPeerDependencies, ...this.buildOptions.exclude], peerDependencies @@ -195,6 +224,10 @@ export function nodeExternalsPluginUtilsPath(): string | undefined { * and performance. */ export async function packExternalModules(this: EsbuildServerlessPlugin) { + assert(this.buildOptions, 'buildOptions not defined'); + + const upperPackageJson = findUp('package.json'); + const plugins = this.plugins; if (plugins && plugins.map((plugin) => plugin.name).includes('node-externals')) { @@ -220,14 +253,14 @@ export async function packExternalModules(this: EsbuildServerlessPlugin) { } } - let externals = []; + const externals: string[] = + Array.isArray(this.buildOptions.external) && + this.buildOptions.exclude !== '*' && + !this.buildOptions.exclude.includes('*') + ? without(this.buildOptions.exclude, this.buildOptions.external) + : []; - // get the list of externals only if exclude is not set to * - if (this.buildOptions.exclude !== '*' && !this.buildOptions.exclude.includes('*')) { - externals = without(this.buildOptions.exclude, this.buildOptions.external); - } - - if (!externals || !externals.length) { + if (!externals.length) { return; } @@ -235,11 +268,13 @@ export async function packExternalModules(this: EsbuildServerlessPlugin) { // get the root package.json by looking up until we hit a lockfile // if this is a yarn workspace, it will be the monorepo package.json const rootPackageJsonPath = path.join(findProjectRoot() || '', './package.json'); - // get the local package.json by looking up until we hit a package.json file // if this is *not* a yarn workspace, it will be the same as rootPackageJsonPath const packageJsonPath = - this.buildOptions.packagePath || path.relative(process.cwd(), path.join(findUp('package.json'), './package.json')); + this.buildOptions.packagePath || + (upperPackageJson && path.relative(process.cwd(), path.join(upperPackageJson, './package.json'))); + + assert(packageJsonPath, 'packageJsonPath is not defined'); // Determine and create packager const packager = await getPackager.call(this, this.buildOptions.packager); @@ -247,20 +282,29 @@ export async function packExternalModules(this: EsbuildServerlessPlugin) { // Fetch needed original package.json sections const sectionNames = packager.copyPackageSectionNames; + type ScriptsRecord = Record<`script${number}`, string>; + // Get scripts from packager options - const packagerScripts = this.buildOptions.packagerOptions - ? [].concat(this.buildOptions.packagerOptions.scripts || []).reduce((scripts, script, index) => { - scripts[`script${index}`] = script; - return scripts; - }, {}) - : {}; + const packagerScripts: ScriptsRecord = + typeof this.buildOptions.packagerOptions?.scripts !== 'undefined' + ? (Array.isArray(this.buildOptions.packagerOptions.scripts) + ? this.buildOptions.packagerOptions.scripts + : [this.buildOptions.packagerOptions.scripts] + ).reduce((scripts, script, index) => { + scripts[`script${index}`] = script; + + return scripts; + }, {}) + : {}; const rootPackageJson: Record = this.serverless.utils.readFileSync(rootPackageJsonPath); const isWorkspace = !!rootPackageJson.workspaces; const packageJson: Record = isWorkspace - ? this.serverless.utils.readFileSync(packageJsonPath) + ? packageJsonPath + ? this.serverless.utils.readFileSync(packageJsonPath) + : {} : rootPackageJson; const packageSections = pick(sectionNames, packageJson); @@ -286,6 +330,9 @@ export async function packExternalModules(this: EsbuildServerlessPlugin) { // (1.a) Install all needed modules const compositeModulePath = this.buildDirPath; + + assertIsString(compositeModulePath, 'compositeModulePath is not a string'); + const compositePackageJson = path.join(compositeModulePath, 'package.json'); // (1.a.1) Create a package.json @@ -319,8 +366,8 @@ export async function packExternalModules(this: EsbuildServerlessPlugin) { path.join(compositeModulePath, packager.lockfileName), packageLockFile as string ); - } catch (err) { - this.log.warning(`Warning: Could not read lock file: ${err.message}`); + } catch (error) { + this.log.warning(`Warning: Could not read lock file${error instanceof Error ? `: ${error.message}` : ''}`); } } @@ -342,6 +389,8 @@ export async function packExternalModules(this: EsbuildServerlessPlugin) { this.log.debug(`Prune: ${compositeModulePath} [${Date.now() - startPrune} ms]`); + assertIsString(this.buildDirPath, 'buildDirPath is not a string'); + // Run packager scripts if (Object.keys(packagerScripts).length > 0) { const startScripts = Date.now(); diff --git a/src/pack.ts b/src/pack.ts index d8e0082c..391b1f03 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -6,18 +6,25 @@ import { intersection, isEmpty, lensProp, map, over, pipe, reject, replace, test import semver from 'semver'; import { ONLY_PREFIX, SERVERLESS_FOLDER } from './constants'; -import { doSharePath, flatDep, getDepsFromBundle, isESM } from './helper'; +import { assertIsString, doSharePath, flatDep, getDepsFromBundle, isESM } from './helper'; import { getPackager } from './packagers'; import { humanSize, zip, trimExtension } from './utils'; import type EsbuildServerlessPlugin from './index'; import type { IFiles } from './types'; - -function setFunctionArtifactPath(this: EsbuildServerlessPlugin, func, artifactPath) { +import type Serverless from 'serverless'; +import assert from 'assert'; + +function setFunctionArtifactPath( + this: EsbuildServerlessPlugin, + func: Serverless.FunctionDefinitionHandler, + artifactPath: string +) { const version = this.serverless.getVersion(); // Serverless changed the artifact path location in version 1.18 if (semver.lt(version, '1.18.0')) { - func.artifact = artifactPath; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (func as any).artifact = artifactPath; func.package = Object.assign({}, func.package, { disable: true }); this.log.verbose(`${func.name} is packaged by the esbuild plugin. Ignore messages from SLS.`); } else { @@ -79,18 +86,24 @@ export async function pack(this: EsbuildServerlessPlugin) { const excludedFiles = isGoogleProvider ? [] : excludedFilesDefault; // Google provider cannot use individual packaging for now - this could be built in a future release - if (isGoogleProvider && this.serverless?.service?.package?.individually) + if (isGoogleProvider && this.serverless?.service?.package?.individually) { throw new Error('Packaging failed: cannot package function individually when using Google provider'); + } + + const { buildDirPath, workDirPath } = this; + + assertIsString(buildDirPath, 'buildDirPath is not a string'); + assertIsString(workDirPath, 'workDirPath is not a string'); // get a list of all path in build const files: IFiles = globby .sync('**', { - cwd: this.buildDirPath, + cwd: buildDirPath, dot: true, onlyFiles: true, }) .filter((p) => !excludedFiles.includes(p)) - .map((localPath) => ({ localPath, rootPath: path.join(this.buildDirPath, localPath) })); + .map((localPath) => ({ localPath, rootPath: path.join(buildDirPath, localPath) })); if (isEmpty(files)) { console.log('Packaging: No files found. Skipping esbuild.'); @@ -100,7 +113,7 @@ export async function pack(this: EsbuildServerlessPlugin) { // 1) If individually is not set, just zip the all build dir and return if (!this.serverless?.service?.package?.individually) { const zipName = `${this.serverless.service.service}.zip`; - const artifactPath = path.join(this.workDirPath, SERVERLESS_FOLDER, zipName); + const artifactPath = path.join(workDirPath, SERVERLESS_FOLDER, zipName); // remove prefixes from individual extra files const filesPathList = pipe( @@ -109,7 +122,7 @@ export async function pack(this: EsbuildServerlessPlugin) { )(files); const startZip = Date.now(); - await zip(artifactPath, filesPathList, this.buildOptions.nativeZip); + await zip(artifactPath, filesPathList, this.buildOptions?.nativeZip); const { size } = fs.statSync(artifactPath); this.log.verbose( @@ -120,24 +133,31 @@ export async function pack(this: EsbuildServerlessPlugin) { return; } + assertIsString(this.buildOptions?.packager, 'packager is not a string'); + // 2) If individually is set, we'll optimize files and zip per-function const packager = await getPackager.call(this, this.buildOptions.packager); // get a list of every function bundle - const buildResults = this.buildResults; + const { buildResults } = this; + + assert(buildResults, 'buildResults is not an array'); + const bundlePathList = buildResults.map((b) => b.bundlePath); - let externals = []; + let externals: string[] = []; // get the list of externals to include only if exclude is not set to * if (this.buildOptions.exclude !== '*' && !this.buildOptions.exclude.includes('*')) { - externals = without(this.buildOptions.exclude, this.buildOptions.external); + externals = without(this.buildOptions.exclude, this.buildOptions.external ?? []); } const hasExternals = !!externals?.length; + const { buildOptions } = this; + // get a tree of all production dependencies - const packagerDependenciesList = hasExternals ? await packager.getProdDependencies(this.buildDirPath) : {}; + const packagerDependenciesList = hasExternals ? await packager.getProdDependencies(buildDirPath) : {}; const packageFiles = await globby(this.serverless.service.package.patterns); @@ -146,21 +166,23 @@ export async function pack(this: EsbuildServerlessPlugin) { buildResults.map(async ({ func, functionAlias, bundlePath }) => { const excludedFiles = bundlePathList.filter((p) => !bundlePath.startsWith(p)).map(trimExtension); + assert(func.package?.patterns); + const functionFiles = await globby(func.package.patterns); const includedFiles = [...packageFiles, ...functionFiles]; // allowed external dependencies in the final zip - let depWhiteList = []; + let depWhiteList: string[] = []; - if (hasExternals) { - const bundleDeps = getDepsFromBundle(path.join(this.buildDirPath, bundlePath), isESM(this.buildOptions)); + if (hasExternals && packagerDependenciesList.dependencies) { + const bundleDeps = getDepsFromBundle(path.join(buildDirPath, bundlePath), isESM(buildOptions)); const bundleExternals = intersection(bundleDeps, externals); depWhiteList = flatDep(packagerDependenciesList.dependencies, bundleExternals); } const zipName = `${functionAlias}.zip`; - const artifactPath = path.join(this.workDirPath, SERVERLESS_FOLDER, zipName); + const artifactPath = path.join(workDirPath, SERVERLESS_FOLDER, zipName); // filter files const filesPathList = filterFilesForZipPackage({ @@ -179,7 +201,7 @@ export async function pack(this: EsbuildServerlessPlugin) { })); const startZip = Date.now(); - await zip(artifactPath, filesPathList, this.buildOptions.nativeZip); + await zip(artifactPath, filesPathList, buildOptions.nativeZip); const { size } = fs.statSync(artifactPath); diff --git a/src/packagers/npm.ts b/src/packagers/npm.ts index dba87d9f..c244c9e0 100644 --- a/src/packagers/npm.ts +++ b/src/packagers/npm.ts @@ -1,9 +1,10 @@ import { any, isEmpty, replace, split, startsWith, takeWhile } from 'ramda'; import * as path from 'path'; -import { DependenciesResult, DependencyMap, JSONObject } from '../types'; +import type { DependenciesResult, DependencyMap, JSONObject } from '../types'; import { SpawnError, spawnProcess } from '../utils'; -import { Packager } from './packager'; +import type { Packager } from './packager'; +import { isString } from '../helper'; type NpmV7Map = Record; @@ -107,21 +108,33 @@ export class NPM implements Packager { const processOutput = await spawnProcess(command, args, { cwd }); const version = processOutput.stdout.trim(); - return parseInt(version.split('.')[0]); + + const [major] = version.split('.'); + + if (major) { + return parseInt(major); + } + + throw new Error('Unable to get major npm version'); } async getProdDependencies(cwd: string, depth?: number): Promise { + const npmMajorVersion = await this.getNpmMajorVersion(cwd); + // Get first level dependency graph const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; const args = [ 'ls', '-json', - '-prod', // Only prod dependencies + npmMajorVersion >= 7 ? '--omit=dev' : '-prod', // Only prod dependencies '-long', - depth ? `-depth=${depth}` : (await this.getNpmMajorVersion(cwd)) >= 7 ? '-all' : null, - ].filter(Boolean); + depth ? `-depth=${depth}` : npmMajorVersion >= 7 ? '-all' : null, + ].filter(isString); - const ignoredNpmErrors = [ + const ignoredNpmErrors: Array<{ + npmError: string; + log: boolean; + }> = [ { npmError: 'extraneous', log: false }, { npmError: 'missing', log: false }, { npmError: 'peer dep missing', log: true }, @@ -129,13 +142,15 @@ export class NPM implements Packager { ]; let parsedDeps: NpmV6Deps | NpmV7Deps; + try { const processOutput = await spawnProcess(command, args, { cwd }); + parsedDeps = JSON.parse(processOutput.stdout) as NpmV6Deps | NpmV7Deps; } catch (err) { if (err instanceof SpawnError) { // Only exit with an error if we have critical npm errors for 2nd level inside - // Split the stderr by \n character to get the npm ERR! plaintext lines, ignore additonal JSON blob (emitted by npm >=7) + // Split the stderr by \n character to get the npm ERR! plaintext lines, ignore additional JSON blob (emitted by npm >=7) // see https://github.com/serverless-heaven/serverless-webpack/pull/782 and https://github.com/floydspace/serverless-esbuild/issues/288 const lines = split('\n', err.stderr); const npmErrors = takeWhile((line) => line !== '{', lines); @@ -262,14 +277,14 @@ export class NPM implements Packager { await spawnProcess(command, args, { cwd }); } - async prune(cwd) { + async prune(cwd: string) { const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; const args = ['prune']; await spawnProcess(command, args, { cwd }); } - async runScripts(cwd, scriptNames) { + async runScripts(cwd: string, scriptNames: string[]) { const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; await Promise.all( diff --git a/src/packagers/packager.ts b/src/packagers/packager.ts index fc6907d0..530050a1 100644 --- a/src/packagers/packager.ts +++ b/src/packagers/packager.ts @@ -1,4 +1,4 @@ -import { DependenciesResult, JSONObject } from '../types'; +import type { DependenciesResult, JSONObject } from '../types'; export interface Packager { lockfileName: string; @@ -8,5 +8,5 @@ export interface Packager { rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject): JSONObject; install(cwd: string, extraArgs: Array, useLockfile?: boolean): Promise; prune(cwd: string): Promise; - runScripts(cwd: string, scriptNames): Promise; + runScripts(cwd: string, scriptNames: string[]): Promise; } diff --git a/src/packagers/pnpm.ts b/src/packagers/pnpm.ts index 369f46d4..8bd48d22 100644 --- a/src/packagers/pnpm.ts +++ b/src/packagers/pnpm.ts @@ -1,8 +1,9 @@ -import { any, isEmpty, reduce, replace, split, startsWith } from 'ramda'; +import { isEmpty, reduce, replace, split, startsWith } from 'ramda'; +import { isString } from '../helper'; -import { JSONObject } from '../types'; +import type { JSONObject } from '../types'; import { SpawnError, spawnProcess } from '../utils'; -import { Packager } from './packager'; +import type { Packager } from './packager'; /** * pnpm packager. @@ -28,10 +29,13 @@ export class Pnpm implements Packager { '--prod', // Only prod dependencies '--json', depth ? `--depth=${depth}` : null, - ].filter(Boolean); + ].filter(isString); // If we need to ignore some errors add them here - const ignoredPnpmErrors = []; + const ignoredPnpmErrors: Array<{ + npmError: string; + log: boolean; + }> = []; try { const processOutput = await spawnProcess(command, args, { cwd }); @@ -49,7 +53,7 @@ export class Pnpm implements Packager { } return ( !isEmpty(error) && - !any((ignoredError) => startsWith(`npm ERR! ${ignoredError.npmError}`, error), ignoredPnpmErrors) + !ignoredPnpmErrors.some((ignoredError) => startsWith(`npm ERR! ${ignoredError.npmError}`, error)) ); }, false, @@ -92,7 +96,7 @@ export class Pnpm implements Packager { return lockfile; } - async install(cwd, extraArgs: Array, useLockfile = true) { + async install(cwd: string, extraArgs: string[], useLockfile = true) { const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; const args = useLockfile ? ['install', '--frozen-lockfile', ...extraArgs] : ['install', ...extraArgs]; @@ -100,14 +104,14 @@ export class Pnpm implements Packager { await spawnProcess(command, args, { cwd }); } - async prune(cwd) { + async prune(cwd: string) { const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; const args = ['prune']; await spawnProcess(command, args, { cwd }); } - async runScripts(cwd, scriptNames) { + async runScripts(cwd: string, scriptNames: string[]) { const command = /^win/.test(process.platform) ? 'pnpm.cmd' : 'pnpm'; await Promise.all( diff --git a/src/packagers/yarn.ts b/src/packagers/yarn.ts index 4ad735bb..cac6583f 100644 --- a/src/packagers/yarn.ts +++ b/src/packagers/yarn.ts @@ -1,13 +1,14 @@ import { any, isEmpty, reduce, replace, split, startsWith } from 'ramda'; -import { DependenciesResult, DependencyMap } from '../types'; +import type { DependenciesResult, DependencyMap } from '../types'; import { SpawnError, spawnProcess } from '../utils'; -import { Packager } from './packager'; +import type { Packager } from './packager'; import { satisfies } from 'semver'; +import { isString } from '../helper'; interface YarnTree { name: string; - color: 'bold' | 'dim'; + color: 'bold' | 'dim' | null; children?: YarnTree[]; hint?: null; depth?: number; @@ -52,10 +53,13 @@ export class Yarn implements Packager { async getProdDependencies(cwd: string, depth?: number): Promise { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; - const args = ['list', depth ? `--depth=${depth}` : null, '--json', '--production'].filter(Boolean); + const args = ['list', depth ? `--depth=${depth}` : null, '--json', '--production'].filter(isString); // If we need to ignore some errors add them here - const ignoredYarnErrors = []; + const ignoredYarnErrors: Array<{ + npmError: string; + log: boolean; + }> = []; let parsedDeps: YarnDeps; try { @@ -102,9 +106,11 @@ export class Yarn implements Packager { return trees.reduce((deps, tree) => { const { name, version } = getNameAndVersion(tree.name); + const dependency = rootDependencies[name]; + if (tree.shadow) { // Package is resolved somewhere else - if (satisfies(rootDependencies[name].version, version)) { + if (dependency && satisfies(dependency.version, version)) { // Package is at root level // { // "name": "samchungy-dep-a@1.0.0", <- MATCH @@ -178,21 +184,28 @@ export class Yarn implements Packager { }; } - rebaseLockfile(pathToPackageRoot, lockfile) { + rebaseLockfile(pathToPackageRoot: string, lockfile: string) { const fileVersionMatcher = /[^"/]@(?:file:)?((?:\.\/|\.\.\/).*?)[":,]/gm; - const replacements = []; + const replacements: Array<{ + oldRef: string; + newRef: string; + }> = []; let match; // Detect all references and create replacement line strings while ((match = fileVersionMatcher.exec(lockfile)) !== null) { replacements.push({ - oldRef: match[1], + oldRef: typeof match[1] === 'string' ? match[1] : '', newRef: replace(/\\/g, '/', `${pathToPackageRoot}/${match[1]}`), }); } // Replace all lines in lockfile - return reduce((__, replacement) => replace(replacement.oldRef, replacement.newRef, __), lockfile, replacements); + return reduce( + (__, replacement) => replace(replacement.oldRef, replacement.newRef, __), + lockfile, + replacements.filter((item) => item.oldRef !== '') + ); } async install(cwd: string, extraArgs: Array, useLockfile = true) { @@ -206,11 +219,11 @@ export class Yarn implements Packager { } // "Yarn install" prunes automatically - prune(cwd) { + prune(cwd: string) { return this.install(cwd, []); } - async runScripts(cwd, scriptNames: string[]) { + async runScripts(cwd: string, scriptNames: string[]) { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; await Promise.all(scriptNames.map((scriptName) => spawnProcess(command, ['run', scriptName], { cwd }))); } diff --git a/src/pre-local.ts b/src/pre-local.ts index ddd8be73..b1ec441f 100644 --- a/src/pre-local.ts +++ b/src/pre-local.ts @@ -1,10 +1,14 @@ -import EsbuildServerlessPlugin from '.'; +import { assertIsString } from './helper'; +import type EsbuildServerlessPlugin from './index'; export function preLocal(this: EsbuildServerlessPlugin) { + assertIsString(this.buildDirPath); + this.serviceDirPath = this.buildDirPath; this.serverless.config.servicePath = this.buildDirPath; + // If this is a node function set the service path as CWD to allow accessing bundled files correctly - if (this.functions[this.options.function]) { + if (this.options.function && this.functions[this.options.function]) { process.chdir(this.serviceDirPath); } } diff --git a/src/pre-offline.ts b/src/pre-offline.ts index c5a9a581..36bc471f 100644 --- a/src/pre-offline.ts +++ b/src/pre-offline.ts @@ -1,8 +1,12 @@ import { relative } from 'path'; import { assocPath } from 'ramda'; -import EsbuildServerlessPlugin from '.'; +import { assertIsString } from './helper'; + +import type EsbuildServerlessPlugin from './index'; export function preOffline(this: EsbuildServerlessPlugin) { + assertIsString(this.buildDirPath); + // Set offline location automatically if not set manually if (!this.serverless?.service?.custom?.['serverless-offline']?.location) { const newServerless = assocPath( diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index cd9aa817..8f7e7145 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -71,7 +71,7 @@ afterEach(() => { describe('Move Artifacts', () => { it('should copy files from the esbuild folder to the serverless folder', async () => { const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(), mockOptions); - plugin.hooks.initialize(); + plugin.hooks.initialize?.(); await plugin.moveArtifacts(); @@ -85,7 +85,7 @@ describe('Move Artifacts', () => { ...mockOptions, function: 'hello1', }); - plugin.hooks.initialize(); + plugin.hooks.initialize?.(); await plugin.moveArtifacts(); @@ -106,7 +106,7 @@ describe('Move Artifacts', () => { describe('package individually', () => { it('should update function package artifacts base path to the serverless folder', async () => { const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(), mockOptions); - plugin.hooks.initialize(); + plugin.hooks.initialize?.(); await plugin.moveArtifacts(); @@ -140,7 +140,7 @@ describe('Move Artifacts', () => { }), mockOptions ); - plugin.hooks.initialize(); + plugin.hooks.initialize?.(); await plugin.moveArtifacts(); @@ -168,7 +168,7 @@ describe('Move Artifacts', () => { describe('service package', () => { it('should update the service package artifact base path to the serverless folder', async () => { const plugin = new EsbuildServerlessPlugin(mockServerlessConfig(packageService), mockOptions); - plugin.hooks.initialize(); + plugin.hooks.initialize?.(); await plugin.moveArtifacts(); diff --git a/src/tests/pack.test.ts b/src/tests/pack.test.ts index 2dd54849..ff675a8b 100644 --- a/src/tests/pack.test.ts +++ b/src/tests/pack.test.ts @@ -5,6 +5,7 @@ import fs from 'fs-extra'; import globby from 'globby'; import type { FunctionBuildResult } from '../types'; +import type EsbuildServerlessPlugin from '../index'; jest.mock('globby'); jest.mock('fs-extra'); @@ -115,7 +116,7 @@ describe('pack', () => { }, }; - await pack.call(esbuildPlugin); + await pack.call(esbuildPlugin as unknown as EsbuildServerlessPlugin); expect(zipSpy).toBeCalledWith( '/workdir/serverless-esbuild/examples/individually/.esbuild/.serverless/hello1.zip', diff --git a/src/tests/packagers/npm.test.ts b/src/tests/packagers/npm.test.ts index 70bc8530..38c15e2d 100644 --- a/src/tests/packagers/npm.test.ts +++ b/src/tests/packagers/npm.test.ts @@ -1,5 +1,5 @@ import { NPM, NpmV6Deps, NpmV7Deps } from '../../packagers/npm'; -import { DependenciesResult } from '../../types'; +import type { DependenciesResult } from '../../types'; import * as utils from '../../utils'; jest.mock('process'); @@ -48,7 +48,7 @@ describe('NPM Packager', () => { await npm.getProdDependencies(path); expect(spawnSpy).toBeCalledTimes(2); - expect(spawnSpy).toBeCalledWith('npm', ['ls', '-json', '-prod', '-long', '-all'], { + expect(spawnSpy).toBeCalledWith('npm', ['ls', '-json', '--omit=dev', '-long', '-all'], { cwd: './', }); }); @@ -58,7 +58,7 @@ describe('NPM Packager', () => { await npm.getProdDependencies(path, 2); - expect(spawnSpy).toBeCalledTimes(1); + expect(spawnSpy).toBeCalledTimes(2); expect(spawnSpy).toBeCalledWith('npm', ['ls', '-json', '-prod', '-long', '-depth=2'], { cwd: './', }); diff --git a/src/tests/packagers/yarn.test.ts b/src/tests/packagers/yarn.test.ts index c1d58a9d..e0b98d4f 100644 --- a/src/tests/packagers/yarn.test.ts +++ b/src/tests/packagers/yarn.test.ts @@ -1,5 +1,5 @@ import { Yarn, YarnDeps } from '../../packagers/yarn'; -import { DependenciesResult } from '../../types'; +import type { DependenciesResult } from '../../types'; import * as utils from '../../utils'; diff --git a/src/tests/pre-local.test.ts b/src/tests/pre-local.test.ts index 22ffea7b..c3119df4 100644 --- a/src/tests/pre-local.test.ts +++ b/src/tests/pre-local.test.ts @@ -1,5 +1,7 @@ import { preLocal } from '../pre-local'; +import type EsbuildServerlessPlugin from '../index'; + const chdirSpy = jest.spyOn(process, 'chdir').mockImplementation(); afterEach(() => { @@ -19,7 +21,9 @@ it('should call chdir with the buildDirPath if the invoked function is a node fu hello: {}, }, }; - preLocal.call(esbuildPlugin); + + preLocal.call(esbuildPlugin as unknown as EsbuildServerlessPlugin); + expect(chdirSpy).toBeCalledWith(esbuildPlugin.buildDirPath); }); @@ -34,6 +38,8 @@ it('should not call chdir if the invoked function is not a node function', () => }, functions: {}, }; - preLocal.call(esbuildPlugin); + + preLocal.call(esbuildPlugin as unknown as EsbuildServerlessPlugin); + expect(chdirSpy).not.toBeCalled(); }); diff --git a/src/types.ts b/src/types.ts index a28646b1..569cea10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,11 +19,11 @@ interface NodeExternalsOptions { allowList?: string[]; } -export type EsbuildOptions = Omit; +export type EsbuildOptions = Omit; export interface Configuration extends EsbuildOptions { concurrency?: number; - packager: 'npm' | 'yarn'; + packager: PackagerId; packagePath: string; exclude: '*' | string[]; nativeZip: boolean; @@ -41,7 +41,7 @@ export interface Configuration extends EsbuildOptions { export interface FunctionEntry { entry: string; - func: Serverless.FunctionDefinitionHandler; + func: Serverless.FunctionDefinitionHandler | null; functionAlias?: string; } @@ -81,3 +81,12 @@ export interface IFile { export type IFiles = readonly IFile[]; export type PackagerId = 'npm' | 'pnpm' | 'yarn'; + +export type PackageJSON = { + name: string; + version: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + [key: string]: unknown; +}; diff --git a/src/utils.ts b/src/utils.ts index 513853b8..6deb1146 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,7 @@ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; import { join } from 'ramda'; -import { IFiles } from './types'; +import type { IFiles } from './types'; export class SpawnError extends Error { constructor(message: string, public stdout: string, public stderr: string) { diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..d30d2d13 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": false, + "noEmit": false, + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "src/tests"] +} diff --git a/tsconfig.json b/tsconfig.json index a55ad952..2afa168e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,24 @@ "module": "commonjs", "target": "es2020", "skipLibCheck": true, - "outDir": "dist", "esModuleInterop": true, "resolveJsonModule": true, - "rootDirs": ["e2e"] + "rootDirs": ["e2e"], + "allowJs": true, + "noEmit": true, + "strict": true, + "downlevelIteration": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "importsNotUsedAsValues": "error", + "noErrorTruncation": true, + "noUncheckedIndexedAccess": true }, - "include": ["src"], - "exclude": ["node_modules", "src/tests"] + "include": ["./*.js", "src"], + "exclude": ["node_modules"] }