From 6962abf29f265d4af333e5e17a0063de264de94e Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Thu, 17 Feb 2022 19:12:03 +0100 Subject: [PATCH 1/8] feat: Implement watch mode Closes #2 --- README.md | 47 ++- package-lock.json | 16 +- package.json | 2 +- src/cli.ts | 14 +- src/lib/builder.spec.ts | 305 +++++++++++++++++- src/lib/builder.ts | 72 +++-- src/lib/errors/invalid-json-error.ts | 2 +- src/lib/errors/no-entry-points-error.ts | 2 +- src/lib/errors/project-directory-not-found.ts | 2 +- src/lib/logger.ts | 9 +- src/lib/plugins/dirname.ts | 1 + 11 files changed, 425 insertions(+), 47 deletions(-) create mode 100644 src/lib/plugins/dirname.ts diff --git a/README.md b/README.md index f14e94a..30503d4 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,12 @@ This tool is designed to work with Azure Functions written in TypeScript. It uses [esbuild](https://esbuild.github.io/) to create crazy small bundles. This is especially helpful with cold starts and deployment duration. # Table of Contents -- [Usage](#usage) +- [Build](#build) - [From the CLI](#from-the-cli) -- [Programmatically](#programmatically) + - [Programmatically](#programmatically) +- [Watch mode](#watch-mode) + - [From the CLI](#from-the-cli-1) + - [Programmatically](#programmatically-1) - [Config](#config) - [`project`](#project) - [`entryPoints`](#entrypoints) @@ -21,7 +24,7 @@ This tool is designed to work with Azure Functions written in TypeScript. It use - [Package size](#package-size) - [Build time](#build-time) -## Usage +## Build ### From the CLI @@ -31,7 +34,7 @@ By default, *esbuild-azure-functions* expects a config file called `esbuild-azur npx esbuild-azure-functions [-c ] ``` -## Programmatically +### Programmatically Install *esbuild-azure-functions* into your project @@ -57,6 +60,42 @@ main(); ``` +## Watch mode + +### From the CLI + +By default, *esbuild-azure-functions* expects a config file called `esbuild-azure-functions.config.json` in the directory you are running it from. You can specify a different config location with the `-c | --config` flag. Refer to the [Config section](#config) for config options. + +``` +npx esbuild-azure-functions --watch [-c ] +``` + +### Programmatically + +Install *esbuild-azure-functions* into your project + +``` +npm i --save-dev esbuild-azure-functions +``` + +```ts +import { watch, BuilderConfigType } from 'esbuild-azure-functions'; + +const config: BuilderConfigType = { + project: process.cwd(), + esbuildOptions: { + outdir: 'MyCustomOutDir' + } +}; + +const main = async () => { + await watch(config); +} + +main(); + +``` + ## Config A simple starting config could look like this diff --git a/package-lock.json b/package-lock.json index aebc96f..48a8ef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@types/glob": "^7.2.0", "@types/mocha": "^9.1.0", "@types/mock-fs": "^4.13.1", - "@types/node": "^14.11.2", + "@types/node": "^14.18.12", "@types/rimraf": "^3.0.2", "@types/sinon": "^10.0.11", "c8": "^7.11.0", @@ -42,7 +42,7 @@ "typescript": "^4.4.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=14.18.0" } }, "node_modules/@ampproject/remapping": { @@ -908,9 +908,9 @@ } }, "node_modules/@types/node": { - "version": "14.18.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.10.tgz", - "integrity": "sha512-6iihJ/Pp5fsFJ/aEDGyvT4pHGmCpq7ToQ/yf4bl5SbVAvwpspYJ+v3jO7n8UyjhQVHTy+KNszOozDdv+O6sovQ==", + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", "dev": true }, "node_modules/@types/normalize-package-data": { @@ -7059,9 +7059,9 @@ } }, "@types/node": { - "version": "14.18.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.10.tgz", - "integrity": "sha512-6iihJ/Pp5fsFJ/aEDGyvT4pHGmCpq7ToQ/yf4bl5SbVAvwpspYJ+v3jO7n8UyjhQVHTy+KNszOozDdv+O6sovQ==", + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", "dev": true }, "@types/normalize-package-data": { diff --git a/package.json b/package.json index d6fa234..3b00b4e 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@types/glob": "^7.2.0", "@types/mocha": "^9.1.0", "@types/mock-fs": "^4.13.1", - "@types/node": "^14.11.2", + "@types/node": "^14.18.12", "@types/rimraf": "^3.0.2", "@types/sinon": "^10.0.11", "c8": "^7.11.0", diff --git a/src/cli.ts b/src/cli.ts index 936bafa..20ea5e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,13 +1,17 @@ import { program } from 'commander'; import { loadConfig, parseConfig } from './lib/config-loader'; -import { build } from './lib/builder'; +import { build, watch } from './lib/builder'; import { promises as fs } from 'fs'; const main = async () => { const { name, version, description } = JSON.parse(await fs.readFile(`${__dirname}/../../package.json`, 'utf8')); program.name(name).description(description).version(version, '-v, --version'); - program.requiredOption('-c, --config ', 'the config file to use', './esbuild-azure-functions.config.json'); + + program + .requiredOption('-c, --config ', 'the config file to use', './esbuild-azure-functions.config.json') + .option('-w, --watch', 'enable watch mode', false); + program.showSuggestionAfterError(); program.parse(); @@ -16,7 +20,11 @@ const main = async () => { const file = await loadConfig(options.config); const config = parseConfig(file); - await build(config); + if (!options.watch) { + await build(config); + } else { + await watch(config); + } }; main(); diff --git a/src/lib/builder.spec.ts b/src/lib/builder.spec.ts index 2380645..fd36ada 100644 --- a/src/lib/builder.spec.ts +++ b/src/lib/builder.spec.ts @@ -5,7 +5,7 @@ import mockFS from 'mock-fs'; import path from 'path'; import sinon from 'sinon'; import { ZodError } from 'zod'; -import { build } from './builder'; +import { build, watch } from './builder'; import * as configLoader from './config-loader'; import { InvalidConfigError, NoEntryPointsError, ProjectDirectoryNotFoundError } from './errors'; import * as esbuild from './esbuild'; @@ -39,6 +39,13 @@ describe('Builder', () => { let globStub: sinon.SinonStub; let rimrafStub: sinon.SinonStub; + let mockLogger: { + error: sinon.SinonStub; + warn: sinon.SinonStub; + info: sinon.SinonStub; + verbose: sinon.SinonStub; + }; + beforeEach(() => { sandbox = sinon.createSandbox(); @@ -46,17 +53,19 @@ describe('Builder', () => { [projectDir]: mockFS.directory(), }); + mockLogger = { + error: sandbox.stub(), + warn: sandbox.stub(), + info: sandbox.stub(), + verbose: sandbox.stub(), + }; + esbuildStub = sandbox.stub(esbuild, 'build').resolves(); parseConfigStub = sandbox.stub(configLoader, 'parseConfig'); globStub = sandbox.stub(glob, 'glob').resolves([]); rimrafStub = sandbox.stub(rimraf, 'rimraf').resolves(); - sandbox.stub(logger, 'createLogger').returns({ - error: () => {}, - warn: () => {}, - info: () => {}, - verbose: () => {}, - }); + sandbox.stub(logger, 'createLogger').returns(mockLogger); }); afterEach(() => { @@ -280,4 +289,286 @@ describe('Builder', () => { return expect(build({} as any)).to.eventually.be.rejectedWith(InvalidConfigError); }); }); + + describe('watch', () => { + it('should parse config', async () => { + const config: BuilderConfigType = { + project: projectDir, + entryPoints: ['func/index.ts'], + clean: true, + }; + + parseConfigStub.returns(config); + + await watch(config); + + expect(parseConfigStub.calledOnce).to.be.true; + expect(parseConfigStub.firstCall.args[0]).to.eql(config); + }); + + it('should call esbuild.build with correct config when entryPoints were supplied', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + }; + + parseConfigStub.returns(config); + + await watch(config); + + expect(globStub.called).to.be.false; + expect(rimrafStub.called).to.be.false; + + expect(esbuildStub.calledOnce).to.be.true; + sinon.assert.match(esbuildStub.firstCall.args[0], { + ...expectDefaultConfig, + entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + watch: { + onRebuild: sinon.match.func, + }, + }); + }); + + it('should call esbuild.build with correct config when esbuildOptions were supplied', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + esbuildOptions: { + outdir: 'something', + }, + }; + + parseConfigStub.returns(config); + + await watch(config); + + expect(globStub.called).to.be.false; + expect(rimrafStub.called).to.be.false; + + expect(esbuildStub.calledOnce).to.be.true; + sinon.assert.match(esbuildStub.firstCall.args[0], { + ...expectDefaultConfig, + ...config.esbuildOptions, + entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + watch: { + onRebuild: sinon.match.func, + }, + }); + }); + + it('should glob all index.ts files when no entryPoints were supplied', async () => { + const config: BuilderConfigType = { + project: projectDir, + esbuildOptions: { + outdir: 'something', + }, + }; + + const files = [`${process.cwd()}/${projectDir}/func1/index.ts`, `${process.cwd()}/${projectDir}/func2/index.ts`]; + globStub.resolves(files); + + parseConfigStub.returns(config); + + await watch(config); + + expect(rimrafStub.called).to.be.false; + + expect(esbuildStub.calledOnce).to.be.true; + sinon.assert.match(esbuildStub.firstCall.args[0], { + ...expectDefaultConfig, + ...config.esbuildOptions, + entryPoints: files, + watch: { + onRebuild: sinon.match.func, + }, + }); + expect(globStub.calledOnce).to.be.true; + expect(globStub.firstCall.args).to.eql([ + '**/index.ts', + { + cwd: projectDir, + absolute: true, + ignore: ['**/node_modules/**'], + }, + ]); + }); + + it('should glob all index.ts files when no entryPoints but excludes were supplied', async () => { + const config: BuilderConfigType = { + project: projectDir, + exclude: ['dir1'], + esbuildOptions: { + outdir: 'something', + }, + }; + + const files = [`${process.cwd()}/${projectDir}/func1/index.ts`, `${process.cwd()}/${projectDir}/func2/index.ts`]; + globStub.resolves(files); + + parseConfigStub.returns(config); + + await watch(config); + + expect(rimrafStub.called).to.be.false; + + expect(esbuildStub.calledOnce).to.be.true; + sinon.assert.match(esbuildStub.firstCall.args[0], { + ...expectDefaultConfig, + ...config.esbuildOptions, + entryPoints: files, + watch: { + onRebuild: sinon.match.func, + }, + }); + expect(globStub.calledOnce).to.be.true; + expect(globStub.firstCall.args).to.eql([ + '**/index.ts', + { + cwd: projectDir, + absolute: true, + ignore: ['**/node_modules/**', ...config.exclude!], + }, + ]); + }); + + it('should rimraf outdir when clean is true', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + const outdir = 'someOutdir'; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + clean: true, + esbuildOptions: { + outdir, + }, + }; + + parseConfigStub.returns(config); + + await watch(config); + + expect(rimrafStub.calledOnce).to.be.true; + expect(rimrafStub.firstCall.args[0]).to.eql(outdir); + }); + + it('should fix outdir when only one entry point is found', async () => { + const outdir = 'some/outdir'; + + const config: BuilderConfigType = { + project: projectDir, + exclude: ['dir1'], + esbuildOptions: { + outdir, + }, + }; + + const files = [`${process.cwd()}/${projectDir}/func1/index.ts`]; + globStub.resolves(files); + + parseConfigStub.returns(config); + + await watch(config); + + expect(rimrafStub.called).to.be.false; + + expect(esbuildStub.calledOnce).to.be.true; + sinon.assert.match(esbuildStub.firstCall.args[0], { + ...expectDefaultConfig, + ...config.esbuildOptions, + entryPoints: files, + outdir: path.join(outdir, path.basename(path.dirname(files[0]))), + watch: { + onRebuild: sinon.match.func, + }, + }); + }); + + it('should call logger.error when onRebuild gets called with error', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + }; + + parseConfigStub.returns(config); + + await watch(config); + + mockLogger.verbose.resetHistory(); + mockLogger.info.resetHistory(); + mockLogger.warn.resetHistory(); + mockLogger.error.resetHistory(); + + esbuildStub.firstCall.args[0].watch.onRebuild({}); + + expect(mockLogger.verbose.called).to.be.false; + expect(mockLogger.info.called).to.be.false; + expect(mockLogger.warn.called).to.be.false; + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('should call logger.info when onRebuild gets called without error', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + }; + + parseConfigStub.returns(config); + + await watch(config); + + mockLogger.verbose.resetHistory(); + mockLogger.info.resetHistory(); + mockLogger.warn.resetHistory(); + mockLogger.error.resetHistory(); + + esbuildStub.firstCall.args[0].watch.onRebuild(); + + expect(mockLogger.verbose.called).to.be.false; + expect(mockLogger.warn.called).to.be.false; + expect(mockLogger.error.called).to.be.false; + expect(mockLogger.info.calledOnce).to.be.true; + }); + + it('should throw NoEntryPointsError when there are no entry points', async () => { + const config: BuilderConfigType = { + project: projectDir, + exclude: ['dir1'], + esbuildOptions: { + outdir: 'something', + }, + }; + + globStub.resolves([]); + + parseConfigStub.returns(config); + + return expect(watch(config)).to.eventually.be.rejectedWith(NoEntryPointsError); + }); + + it('should throw ProjectDirectoryNotFoundError when project dir is invalid', async () => { + const config: BuilderConfigType = { + project: 'some/dir', + entryPoints: ['func1/index.ts'], + }; + + parseConfigStub.returns(config); + + return expect(watch(config)).to.eventually.be.rejectedWith(ProjectDirectoryNotFoundError); + }); + + it('should throw InvalidConfigFileError when parseConfig throws', async () => { + parseConfigStub.throws(new InvalidConfigError(new ZodError([]))); + + return expect(watch({} as any)).to.eventually.be.rejectedWith(InvalidConfigError); + }); + }); }); diff --git a/src/lib/builder.ts b/src/lib/builder.ts index 589da82..d208afd 100644 --- a/src/lib/builder.ts +++ b/src/lib/builder.ts @@ -1,11 +1,11 @@ import { BuildOptions } from 'esbuild'; -import fs from 'fs'; +import { existsSync } from 'fs'; import path from 'path'; import { parseConfig } from './config-loader'; import { NoEntryPointsError, ProjectDirectoryNotFoundError } from './errors'; import * as esbuild from './esbuild'; import { glob } from './glob'; -import { createLogger } from './logger'; +import { createLogger, Logger } from './logger'; import { BuilderConfigType } from './models'; import { rimraf } from './rimraf'; @@ -26,43 +26,70 @@ export async function build(inputConfig: BuilderConfigType) { const config = parseConfig(inputConfig); const logger = createLogger(config.logLevel); - if (!fs.existsSync(config.project)) { - logger.error(`Project path ${config.project} does not exist`); - throw new ProjectDirectoryNotFoundError(config.project); + const esbuildOptions = await _prepare(inputConfig, logger); + + await esbuild.build(esbuildOptions); + + logger.info(`โšก Build complete. Took ${Date.now() - start}ms`); +} + +export async function watch(inputConfig: BuilderConfigType) { + const config = parseConfig(inputConfig); + const logger = createLogger(config.logLevel); + + const esbuildOptions = await _prepare(inputConfig, logger); + + esbuildOptions.watch = { + onRebuild: (error, result) => { + if (error) { + logger.error('โŒ Rebuild failed'); + } else { + logger.info('โšก Rebuild succeeded'); + } + }, + }; + + await esbuild.build(esbuildOptions); +} + +async function _prepare(inputConfig: BuilderConfigType, logger: Logger): Promise { + if (!existsSync(inputConfig.project)) { + logger.error(`Project path ${inputConfig.project} does not exist`); + throw new ProjectDirectoryNotFoundError(inputConfig.project); } - logger.verbose(`๐Ÿ“‚ Project root ${path.resolve(config.project)}`); + logger.verbose(`๐Ÿ“‚ Project root ${path.resolve(inputConfig.project)}`); let entryPoints: string[] = []; - if (config.entryPoints) { - entryPoints = config.entryPoints.map(entryPoint => path.resolve(config.project, entryPoint)); + if (inputConfig.entryPoints) { + entryPoints = inputConfig.entryPoints.map(entryPoint => path.resolve(inputConfig.project, entryPoint)); } else { logger.verbose('๐Ÿ” No entry points specified, looking for index.ts files'); - const exclude = config.exclude || []; + const exclude = inputConfig.exclude || []; entryPoints = await glob('**/index.ts', { - cwd: config.project, + cwd: inputConfig.project, absolute: true, ignore: ['**/node_modules/**', ...exclude], }); } if (entryPoints.length === 0) { - logger.error('No entry points supplied.'); - throw new NoEntryPointsError(config.project); + logger.error('๐Ÿ˜” No entry points available.'); + throw new NoEntryPointsError(inputConfig.project); } logger.verbose(`๐Ÿ”จ Building ${entryPoints.length} entry points`); - const esbuildOptions: BuildOptions = { + const esbuildOptions = { ...defaultConfig, - ...config.esbuildOptions, + ...inputConfig.esbuildOptions, entryPoints, }; - if (config.clean) { + if (inputConfig.clean) { logger.verbose(`๐Ÿงน Cleaning ${esbuildOptions.outdir}`); await rimraf(esbuildOptions.outdir!); @@ -70,11 +97,16 @@ export async function build(inputConfig: BuilderConfigType) { // fix outdir when only one entry point exists because esbuild // doesn't create the correct folder structure - if (entryPoints.length === 1) { - esbuildOptions.outdir = path.join(esbuildOptions.outdir!, path.basename(path.dirname(entryPoints[0]))); + if ( + esbuildOptions.entryPoints && + Array.isArray(esbuildOptions.entryPoints) && + esbuildOptions.entryPoints.length === 1 + ) { + esbuildOptions.outdir = path.join( + esbuildOptions.outdir!, + path.basename(path.dirname(esbuildOptions.entryPoints[0])) + ); } - await esbuild.build(esbuildOptions); - - logger.info(`โšก Build complete. Took ${Date.now() - start}ms`); + return esbuildOptions; } diff --git a/src/lib/errors/invalid-json-error.ts b/src/lib/errors/invalid-json-error.ts index 24f57e9..840f23f 100644 --- a/src/lib/errors/invalid-json-error.ts +++ b/src/lib/errors/invalid-json-error.ts @@ -6,6 +6,6 @@ export class InvalidJSONError extends Error { this.name = this.constructor.name; - // Error.captureStackTrace(this, this.constructor); + Error.captureStackTrace(this, this.constructor); } } diff --git a/src/lib/errors/no-entry-points-error.ts b/src/lib/errors/no-entry-points-error.ts index adad130..c1f58f8 100644 --- a/src/lib/errors/no-entry-points-error.ts +++ b/src/lib/errors/no-entry-points-error.ts @@ -11,6 +11,6 @@ export class NoEntryPointsError extends Error { this.name = this.constructor.name; - // Error.captureStackTrace(this, this.constructor); + Error.captureStackTrace(this, this.constructor); } } diff --git a/src/lib/errors/project-directory-not-found.ts b/src/lib/errors/project-directory-not-found.ts index f3f4327..20304d5 100644 --- a/src/lib/errors/project-directory-not-found.ts +++ b/src/lib/errors/project-directory-not-found.ts @@ -6,6 +6,6 @@ export class ProjectDirectoryNotFoundError extends Error { this.name = this.constructor.name; - // Error.captureStackTrace(this, this.constructor); + Error.captureStackTrace(this, this.constructor); } } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 4a4cebf..f54c251 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,6 +1,13 @@ import { BuilderLogLevelType } from './models'; import { blue, green, yellow, red } from 'colorette'; +export type Logger = { + verbose: (msg: string) => void; + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; +}; + const logLevelMap = { off: -1, error: 0, @@ -10,7 +17,7 @@ const logLevelMap = { }; /* c8 ignore start */ -export function createLogger(logLevel: BuilderLogLevelType = 'error') { +export function createLogger(logLevel: BuilderLogLevelType = 'error'): Logger { const log = (msg: string) => console.log(`${msg}`); const mappedLogLevel = mapLogLevel(logLevel); diff --git a/src/lib/plugins/dirname.ts b/src/lib/plugins/dirname.ts new file mode 100644 index 0000000..8b76d1a --- /dev/null +++ b/src/lib/plugins/dirname.ts @@ -0,0 +1 @@ +export const dirnamePlugin = () => {}; From a2f2fe8b35c60ab2ae65d702ed9fe91e27604f21 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Thu, 17 Feb 2022 20:52:16 +0100 Subject: [PATCH 2/8] chore: Add tests --- src/index.ts | 2 - src/lib/errors/file-system-error.spec.ts | 38 ++++++++++++++ src/lib/errors/invalid-config-error.spec.ts | 52 +++++++++++++++++++ src/lib/errors/invalid-json-error.spec.ts | 38 ++++++++++++++ src/lib/errors/invalid-json-error.ts | 2 +- src/lib/errors/no-entry-points-error.spec.ts | 43 +++++++++++++++ .../project-directory-not-found.spec.ts | 38 ++++++++++++++ 7 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 src/lib/errors/file-system-error.spec.ts create mode 100644 src/lib/errors/invalid-config-error.spec.ts create mode 100644 src/lib/errors/invalid-json-error.spec.ts create mode 100644 src/lib/errors/no-entry-points-error.spec.ts create mode 100644 src/lib/errors/project-directory-not-found.spec.ts diff --git a/src/index.ts b/src/index.ts index 8fd5104..e4c7b64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,2 @@ -/* c8 ignore start */ export * from './lib/builder'; export { BuilderConfigType } from './lib/models'; -/* c8 ignore stop */ diff --git a/src/lib/errors/file-system-error.spec.ts b/src/lib/errors/file-system-error.spec.ts new file mode 100644 index 0000000..360e4b8 --- /dev/null +++ b/src/lib/errors/file-system-error.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { red } from 'colorette'; +import sinon from 'sinon'; +import { FileSystemError } from './file-system-error'; + +describe('FileSystemError', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + sandbox.stub(Error, 'captureStackTrace').callsFake((instance: any) => { + instance.stack = 'ThisIsAStackTrace'; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + it('should set correct message', () => { + const error = new FileSystemError('file', 'code'); + expect(error.message).to.equal(red('An error occurred while accessing "file": code.')); + }); + + it('should set correct stacktrace', () => { + const error = new FileSystemError('file', 'code'); + expect(error.stack).to.equal('ThisIsAStackTrace'); + }); + + it('should set correct name', () => { + const error = new FileSystemError('file', 'code'); + expect(error.name).to.equal('FileSystemError'); + }); + }); +}); diff --git a/src/lib/errors/invalid-config-error.spec.ts b/src/lib/errors/invalid-config-error.spec.ts new file mode 100644 index 0000000..465ba1a --- /dev/null +++ b/src/lib/errors/invalid-config-error.spec.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { red } from 'colorette'; +import sinon from 'sinon'; +import { ZodError, ZodInvalidTypeIssue, ZodIssue } from 'zod'; +import { InvalidConfigError } from './invalid-config-error'; + +describe('InvalidConfigError', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + sandbox.stub(Error, 'captureStackTrace').callsFake((instance: any) => { + instance.stack = 'ThisIsAStackTrace'; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + const zodError = new ZodError([ + { code: 'invalid_type', path: ['path', 'to', 'prop'], expected: 'array', received: 'boolean' } as ZodIssue, + { path: ['path', 'to', 'other', 'prop'] } as ZodIssue, + ]); + + it('should set correct message', () => { + const error = new InvalidConfigError(zodError); + expect(error.message).to.equal( + red( + `\nThe config file is invalid. Check the following properties for validity:\n- ${zodError.issues[0].path.join( + '.' + )}: Expected ${(zodError.issues[0] as ZodInvalidTypeIssue).expected} but received ${ + (zodError.issues[0] as ZodInvalidTypeIssue).received + }\n- ${zodError.issues[1].path.join('.')}\n` + ) + ); + }); + + it('should set correct stacktrace', () => { + const error = new InvalidConfigError(zodError); + expect(error.stack).to.equal('ThisIsAStackTrace'); + }); + + it('should set correct name', () => { + const error = new InvalidConfigError(zodError); + expect(error.name).to.equal('InvalidConfigError'); + }); + }); +}); diff --git a/src/lib/errors/invalid-json-error.spec.ts b/src/lib/errors/invalid-json-error.spec.ts new file mode 100644 index 0000000..0a4def6 --- /dev/null +++ b/src/lib/errors/invalid-json-error.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { red } from 'colorette'; +import sinon from 'sinon'; +import { InvalidJSONError } from './invalid-json-error'; + +describe('InvalidJSONError', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + sandbox.stub(Error, 'captureStackTrace').callsFake((instance: any) => { + instance.stack = 'ThisIsAStackTrace'; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + it('should set correct message', () => { + const error = new InvalidJSONError('file'); + expect(error.message).to.equal(red('The config file "file" does not contain valid JSON.')); + }); + + it('should set correct stacktrace', () => { + const error = new InvalidJSONError('file'); + expect(error.stack).to.equal('ThisIsAStackTrace'); + }); + + it('should set correct name', () => { + const error = new InvalidJSONError('file'); + expect(error.name).to.equal('InvalidJSONError'); + }); + }); +}); diff --git a/src/lib/errors/invalid-json-error.ts b/src/lib/errors/invalid-json-error.ts index 840f23f..91da372 100644 --- a/src/lib/errors/invalid-json-error.ts +++ b/src/lib/errors/invalid-json-error.ts @@ -2,7 +2,7 @@ import { red } from 'colorette'; export class InvalidJSONError extends Error { constructor(file: string) { - super(red(`The config file ${file} does not contain valid JSON.`)); + super(red(`The config file "${file}" does not contain valid JSON.`)); this.name = this.constructor.name; diff --git a/src/lib/errors/no-entry-points-error.spec.ts b/src/lib/errors/no-entry-points-error.spec.ts new file mode 100644 index 0000000..6ead43f --- /dev/null +++ b/src/lib/errors/no-entry-points-error.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import { red } from 'colorette'; +import sinon from 'sinon'; +import { NoEntryPointsError } from './no-entry-points-error'; + +describe('NoEntryPointsError', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + sandbox.stub(Error, 'captureStackTrace').callsFake((instance: any) => { + instance.stack = 'ThisIsAStackTrace'; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + it('should set correct message', () => { + const error = new NoEntryPointsError('dir'); + expect(error.message).to.equal( + red(` + The project directory "dir" did not contain any entry points and none were supplied. + Make sure your project contains at least one index.ts file + or supply entry points manually through config.entryPoints.`) + ); + }); + + it('should set correct stacktrace', () => { + const error = new NoEntryPointsError('dir'); + expect(error.stack).to.equal('ThisIsAStackTrace'); + }); + + it('should set correct name', () => { + const error = new NoEntryPointsError('dir'); + expect(error.name).to.equal('NoEntryPointsError'); + }); + }); +}); diff --git a/src/lib/errors/project-directory-not-found.spec.ts b/src/lib/errors/project-directory-not-found.spec.ts new file mode 100644 index 0000000..26c3f07 --- /dev/null +++ b/src/lib/errors/project-directory-not-found.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { red } from 'colorette'; +import sinon from 'sinon'; +import { ProjectDirectoryNotFoundError } from './project-directory-not-found'; + +describe('ProjectDirectoryNotFoundError', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + sandbox.stub(Error, 'captureStackTrace').callsFake((instance: any) => { + instance.stack = 'ThisIsAStackTrace'; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + it('should set correct message', () => { + const error = new ProjectDirectoryNotFoundError('dir'); + expect(error.message).to.equal(red('The project directory "dir" could not be found.')); + }); + + it('should set correct stacktrace', () => { + const error = new ProjectDirectoryNotFoundError('dir'); + expect(error.stack).to.equal('ThisIsAStackTrace'); + }); + + it('should set correct name', () => { + const error = new ProjectDirectoryNotFoundError('dir'); + expect(error.name).to.equal('ProjectDirectoryNotFoundError'); + }); + }); +}); From 9ab74adb3e2705a5e6a744bcbb907cdb5b566abc Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Thu, 17 Feb 2022 21:12:33 +0100 Subject: [PATCH 3/8] fix: Change default esbuild config Closes #1 --- README.md | 3 ++- src/lib/builder.spec.ts | 3 ++- src/lib/builder.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 30503d4..b792e79 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,8 @@ A simple starting config could look like this platform: 'node', splitting: true, format: 'esm', - outdir: 'build', + outdir: 'dist', + outExtension: { '.js': '.mjs' }, } ``` diff --git a/src/lib/builder.spec.ts b/src/lib/builder.spec.ts index fd36ada..24172a8 100644 --- a/src/lib/builder.spec.ts +++ b/src/lib/builder.spec.ts @@ -26,7 +26,8 @@ const expectDefaultConfig: BuildOptions = { platform: 'node', splitting: true, format: 'esm', - outdir: 'build', + outdir: 'dist', + outExtension: { '.js': '.mjs' }, }; const projectDir = 'my/project'; diff --git a/src/lib/builder.ts b/src/lib/builder.ts index d208afd..003f989 100644 --- a/src/lib/builder.ts +++ b/src/lib/builder.ts @@ -17,7 +17,8 @@ const defaultConfig: BuildOptions = { platform: 'node', splitting: true, format: 'esm', - outdir: 'build', + outdir: 'dist', + outExtension: { '.js': '.mjs' }, }; export async function build(inputConfig: BuilderConfigType) { From c559f4b237e9831b67289da7b1fedd83828cab80 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 18 Feb 2022 22:55:48 +0100 Subject: [PATCH 4/8] feat: Implement dirname plugin Closes #3 --- package-lock.json | 36 ++++++- package.json | 1 + src/lib/builder.spec.ts | 185 ++++++++++++++++++++++++-------- src/lib/builder.ts | 45 +++++--- src/lib/models/config.spec.ts | 119 -------------------- src/lib/models/config.ts | 10 +- src/lib/plugins/dirname.spec.ts | 80 ++++++++++++++ src/lib/plugins/dirname.ts | 40 ++++++- src/lib/plugins/index.ts | 1 + 9 files changed, 338 insertions(+), 179 deletions(-) delete mode 100644 src/lib/models/config.spec.ts create mode 100644 src/lib/plugins/dirname.spec.ts create mode 100644 src/lib/plugins/index.ts diff --git a/package-lock.json b/package-lock.json index 48a8ef2..44c1fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "c8": "^7.11.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", + "chai-exclude": "^2.1.0", "gts": "^3.1.0", "mocha": "^9.2.0", "mocha-multi-reporters": "^1.5.1", @@ -42,7 +43,7 @@ "typescript": "^4.4.4" }, "engines": { - "node": ">=14.18.0" + "node": ">=14.0.0" } }, "node_modules/@ampproject/remapping": { @@ -1585,6 +1586,18 @@ "chai": ">= 2.1.2 < 5" } }, + "node_modules/chai-exclude": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chai-exclude/-/chai-exclude-2.1.0.tgz", + "integrity": "sha512-IBnm50Mvl3O1YhPpTgbU8MK0Gw7NHcb18WT2TxGdPKOMtdtZVKLHmQwdvOF7mTlHVQStbXuZKFwkevFtbHjpVg==", + "dev": true, + "dependencies": { + "fclone": "^1.0.11" + }, + "peerDependencies": { + "chai": ">= 4.0.0 < 5" + } + }, "node_modules/chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -2806,6 +2819,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fclone": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", + "integrity": "sha1-EOhdo4v+p/xZk0HClu4ddyZu5kA=", + "dev": true + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7539,6 +7558,15 @@ "check-error": "^1.0.2" } }, + "chai-exclude": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chai-exclude/-/chai-exclude-2.1.0.tgz", + "integrity": "sha512-IBnm50Mvl3O1YhPpTgbU8MK0Gw7NHcb18WT2TxGdPKOMtdtZVKLHmQwdvOF7mTlHVQStbXuZKFwkevFtbHjpVg==", + "dev": true, + "requires": { + "fclone": "^1.0.11" + } + }, "chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -8360,6 +8388,12 @@ "reusify": "^1.0.4" } }, + "fclone": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", + "integrity": "sha1-EOhdo4v+p/xZk0HClu4ddyZu5kA=", + "dev": true + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", diff --git a/package.json b/package.json index 3b00b4e..48f4872 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "c8": "^7.11.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", + "chai-exclude": "^2.1.0", "gts": "^3.1.0", "mocha": "^9.2.0", "mocha-multi-reporters": "^1.5.1", diff --git a/src/lib/builder.spec.ts b/src/lib/builder.spec.ts index 24172a8..f616877 100644 --- a/src/lib/builder.spec.ts +++ b/src/lib/builder.spec.ts @@ -1,5 +1,6 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import chaiExclude from 'chai-exclude'; import { BuildOptions } from 'esbuild'; import mockFS from 'mock-fs'; import path from 'path'; @@ -12,18 +13,21 @@ import * as esbuild from './esbuild'; import * as glob from './glob'; import * as logger from './logger'; import { BuilderConfigType } from './models'; +import * as dirnamePlugin from './plugins/dirname'; import * as rimraf from './rimraf'; /* eslint-disable @typescript-eslint/no-explicit-any */ chaiUse(chaiAsPromised); +chaiUse(chaiExclude); -const expectDefaultConfig: BuildOptions = { +const expectedDefaultConfig: BuildOptions = { minify: true, bundle: true, sourcemap: false, watch: false, platform: 'node', + target: 'node12', splitting: true, format: 'esm', outdir: 'dist', @@ -39,6 +43,7 @@ describe('Builder', () => { let parseConfigStub: sinon.SinonStub; let globStub: sinon.SinonStub; let rimrafStub: sinon.SinonStub; + let dirnamePluginStub: sinon.SinonStub; let mockLogger: { error: sinon.SinonStub; @@ -65,6 +70,7 @@ describe('Builder', () => { parseConfigStub = sandbox.stub(configLoader, 'parseConfig'); globStub = sandbox.stub(glob, 'glob').resolves([]); rimrafStub = sandbox.stub(rimraf, 'rimraf').resolves(); + dirnamePluginStub = sandbox.stub(dirnamePlugin, 'dirnamePlugin').returns('dirnamePluginStub' as any); sandbox.stub(logger, 'createLogger').returns(mockLogger); }); @@ -104,11 +110,13 @@ describe('Builder', () => { expect(globStub.called).to.be.false; expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; expect(esbuildStub.firstCall.args[0]).to.eql({ - ...expectDefaultConfig, + ...expectedDefaultConfig, entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + plugins: [], }); }); @@ -129,12 +137,14 @@ describe('Builder', () => { expect(globStub.called).to.be.false; expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; expect(esbuildStub.firstCall.args[0]).to.eql({ - ...expectDefaultConfig, + ...expectedDefaultConfig, ...config.esbuildOptions, entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + plugins: [], }); }); @@ -154,12 +164,14 @@ describe('Builder', () => { await build(config); expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; expect(esbuildStub.firstCall.args[0]).to.eql({ - ...expectDefaultConfig, + ...expectedDefaultConfig, ...config.esbuildOptions, entryPoints: files, + plugins: [], }); expect(globStub.calledOnce).to.be.true; expect(globStub.firstCall.args).to.eql([ @@ -189,12 +201,14 @@ describe('Builder', () => { await build(config); expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; expect(esbuildStub.firstCall.args[0]).to.eql({ - ...expectDefaultConfig, + ...expectedDefaultConfig, ...config.esbuildOptions, entryPoints: files, + plugins: [], }); expect(globStub.calledOnce).to.be.true; expect(globStub.firstCall.args).to.eql([ @@ -247,13 +261,48 @@ describe('Builder', () => { await build(config); expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; expect(esbuildStub.firstCall.args[0]).to.eql({ - ...expectDefaultConfig, + ...expectedDefaultConfig, ...config.esbuildOptions, entryPoints: files, outdir: path.join(outdir, path.basename(path.dirname(files[0]))), + plugins: [], + }); + }); + + it('should add dirname plugin when advancedOptions.enableDirnameShim is true', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + esbuildOptions: { + outdir: 'something', + }, + advancedOptions: { + enableDirnameShim: true, + }, + }; + + parseConfigStub.returns(config); + + await build(config); + + expect(globStub.called).to.be.false; + expect(rimrafStub.called).to.be.false; + + expect(dirnamePluginStub.calledOnce).to.be.true; + + expect(esbuildStub.calledOnce).to.be.true; + expect(esbuildStub.firstCall.args[0]).to.eql({ + ...expectedDefaultConfig, + ...config.esbuildOptions, + entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + metafile: true, + plugins: ['dirnamePluginStub'], }); }); @@ -321,15 +370,17 @@ describe('Builder', () => { expect(globStub.called).to.be.false; expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; - sinon.assert.match(esbuildStub.firstCall.args[0], { - ...expectDefaultConfig, - entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), - watch: { - onRebuild: sinon.match.func, - }, - }); + expect(esbuildStub.firstCall.args[0]) + .excluding('watch') + .to.eql({ + ...expectedDefaultConfig, + entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + plugins: [], + }); + expect(typeof esbuildStub.firstCall.args[0].watch.onRebuild).to.eql('function'); }); it('should call esbuild.build with correct config when esbuildOptions were supplied', async () => { @@ -349,16 +400,18 @@ describe('Builder', () => { expect(globStub.called).to.be.false; expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; - sinon.assert.match(esbuildStub.firstCall.args[0], { - ...expectDefaultConfig, - ...config.esbuildOptions, - entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), - watch: { - onRebuild: sinon.match.func, - }, - }); + expect(esbuildStub.firstCall.args[0]) + .excluding('watch') + .to.eql({ + ...expectedDefaultConfig, + ...config.esbuildOptions, + entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + plugins: [], + }); + expect(typeof esbuildStub.firstCall.args[0].watch.onRebuild).to.eql('function'); }); it('should glob all index.ts files when no entryPoints were supplied', async () => { @@ -377,16 +430,18 @@ describe('Builder', () => { await watch(config); expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; - sinon.assert.match(esbuildStub.firstCall.args[0], { - ...expectDefaultConfig, - ...config.esbuildOptions, - entryPoints: files, - watch: { - onRebuild: sinon.match.func, - }, - }); + expect(esbuildStub.firstCall.args[0]) + .excluding('watch') + .to.eql({ + ...expectedDefaultConfig, + ...config.esbuildOptions, + entryPoints: files, + plugins: [], + }); + expect(typeof esbuildStub.firstCall.args[0].watch.onRebuild).to.eql('function'); expect(globStub.calledOnce).to.be.true; expect(globStub.firstCall.args).to.eql([ '**/index.ts', @@ -415,16 +470,18 @@ describe('Builder', () => { await watch(config); expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; - sinon.assert.match(esbuildStub.firstCall.args[0], { - ...expectDefaultConfig, - ...config.esbuildOptions, - entryPoints: files, - watch: { - onRebuild: sinon.match.func, - }, - }); + expect(esbuildStub.firstCall.args[0]) + .excluding('watch') + .to.eql({ + ...expectedDefaultConfig, + ...config.esbuildOptions, + entryPoints: files, + plugins: [], + }); + expect(typeof esbuildStub.firstCall.args[0].watch.onRebuild).to.eql('function'); expect(globStub.calledOnce).to.be.true; expect(globStub.firstCall.args).to.eql([ '**/index.ts', @@ -476,17 +533,55 @@ describe('Builder', () => { await watch(config); expect(rimrafStub.called).to.be.false; + expect(dirnamePluginStub.called).to.be.false; expect(esbuildStub.calledOnce).to.be.true; - sinon.assert.match(esbuildStub.firstCall.args[0], { - ...expectDefaultConfig, - ...config.esbuildOptions, - entryPoints: files, - outdir: path.join(outdir, path.basename(path.dirname(files[0]))), - watch: { - onRebuild: sinon.match.func, + expect(esbuildStub.firstCall.args[0]) + .excluding('watch') + .to.eql({ + ...expectedDefaultConfig, + ...config.esbuildOptions, + entryPoints: files, + outdir: path.join(outdir, path.basename(path.dirname(files[0]))), + plugins: [], + }); + expect(typeof esbuildStub.firstCall.args[0].watch.onRebuild).to.eql('function'); + }); + + it('should add dirname plugin when advancedOptions.enableDirnameShim is true', async () => { + const entryPoints = ['func1/index.ts', 'func2/index.ts']; + + const config: BuilderConfigType = { + project: projectDir, + entryPoints, + esbuildOptions: { + outdir: 'something', }, - }); + advancedOptions: { + enableDirnameShim: true, + }, + }; + + parseConfigStub.returns(config); + + await watch(config); + + expect(globStub.called).to.be.false; + expect(rimrafStub.called).to.be.false; + + expect(dirnamePluginStub.calledOnce).to.be.true; + + expect(esbuildStub.calledOnce).to.be.true; + expect(esbuildStub.firstCall.args[0]) + .excluding(['watch']) + .to.eql({ + ...expectedDefaultConfig, + ...config.esbuildOptions, + entryPoints: entryPoints.map(entryPoint => `${process.cwd()}/${projectDir}/${entryPoint}`), + metafile: true, + plugins: ['dirnamePluginStub'], + }); + expect(typeof esbuildStub.firstCall.args[0].watch.onRebuild).to.eql('function'); }); it('should call logger.error when onRebuild gets called with error', async () => { diff --git a/src/lib/builder.ts b/src/lib/builder.ts index 003f989..4a0bb08 100644 --- a/src/lib/builder.ts +++ b/src/lib/builder.ts @@ -7,6 +7,7 @@ import * as esbuild from './esbuild'; import { glob } from './glob'; import { createLogger, Logger } from './logger'; import { BuilderConfigType } from './models'; +import { dirnamePlugin } from './plugins'; import { rimraf } from './rimraf'; const defaultConfig: BuildOptions = { @@ -15,6 +16,7 @@ const defaultConfig: BuildOptions = { sourcemap: false, watch: false, platform: 'node', + target: 'node12', splitting: true, format: 'esm', outdir: 'dist', @@ -41,7 +43,7 @@ export async function watch(inputConfig: BuilderConfigType) { const esbuildOptions = await _prepare(inputConfig, logger); esbuildOptions.watch = { - onRebuild: (error, result) => { + onRebuild: error => { if (error) { logger.error('โŒ Rebuild failed'); } else { @@ -84,30 +86,49 @@ async function _prepare(inputConfig: BuilderConfigType, logger: Logger): Promise logger.verbose(`๐Ÿ”จ Building ${entryPoints.length} entry points`); - const esbuildOptions = { + const esbuildOptions: BuildOptions = { ...defaultConfig, ...inputConfig.esbuildOptions, entryPoints, + plugins: _getPlugins(inputConfig), }; - if (inputConfig.clean) { - logger.verbose(`๐Ÿงน Cleaning ${esbuildOptions.outdir}`); - - await rimraf(esbuildOptions.outdir!); - } + await _clean(logger, esbuildOptions.outdir!, inputConfig.clean); // fix outdir when only one entry point exists because esbuild // doesn't create the correct folder structure - if ( - esbuildOptions.entryPoints && - Array.isArray(esbuildOptions.entryPoints) && - esbuildOptions.entryPoints.length === 1 - ) { + if (isSingleEntryPoint(esbuildOptions.entryPoints)) { esbuildOptions.outdir = path.join( esbuildOptions.outdir!, path.basename(path.dirname(esbuildOptions.entryPoints[0])) ); } + if (inputConfig.advancedOptions?.enableDirnameShim) { + esbuildOptions.metafile = true; + } + return esbuildOptions; } + +async function _clean(logger: Logger, dir: string, clean?: boolean) { + if (clean) { + logger.verbose(`๐Ÿงน Cleaning ${dir}`); + + await rimraf(dir); + } +} + +function _getPlugins(config: BuilderConfigType) { + const plugins = config.esbuildOptions?.plugins || []; + + if (config.advancedOptions?.enableDirnameShim) { + plugins.push(dirnamePlugin()); + } + + return plugins; +} + +function isSingleEntryPoint(entryPoints?: string[] | Record): entryPoints is string[] { + return !!entryPoints && Array.isArray(entryPoints) && entryPoints.length === 1; +} diff --git a/src/lib/models/config.spec.ts b/src/lib/models/config.spec.ts deleted file mode 100644 index 2ba47f8..0000000 --- a/src/lib/models/config.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -// import { expect } from 'chai'; -// import sinon from 'sinon'; -// import { isBuilderConfig } from './config'; -// import * as logLevelModel from './loglevel'; - -// describe('BuilderConfig', () => { -// let sandbox: sinon.SinonSandbox; - -// let isBuilderLogLevelStub: sinon.SinonStub; - -// beforeEach(async () => { -// sandbox = sinon.createSandbox(); - -// isBuilderLogLevelStub = sandbox.stub(logLevelModel, 'isBuilderLogLevel').returns(true); -// }); - -// afterEach(() => { -// sandbox.restore(); -// }); - -// describe('isBuilderConfig', () => { -// it('should return false when arg is undefined', () => { -// expect(isBuilderConfig(undefined)).to.be.false; -// }); - -// it('should return false when arg is null', () => { -// expect(isBuilderConfig(null)).to.be.false; -// }); - -// it('should return false when arg.project is undefined', () => { -// const config = {}; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.project is null', () => { -// const config = { -// project: null, -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.project is not a string', () => { -// const config = { -// project: 123, -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.clean is not a boolean', () => { -// const config = { -// project: 'test', -// clean: 123, -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.entryPoints is not an array', () => { -// const config = { -// project: 'test', -// entryPoints: 123, -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.entryPoints is not an array of strings', () => { -// const config = { -// project: 'test', -// entryPoints: [123], -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.exclude is not an array', () => { -// const config = { -// project: 'test', -// exclude: 123, -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when arg.exclude is not an array of strings', () => { -// const config = { -// project: 'test', -// exclude: [123], -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return false when isBuilderLogLevel returns false', () => { -// isBuilderLogLevelStub.returns(false); - -// const config = { -// project: 'test', -// loglevel: 'test', -// }; - -// expect(isBuilderConfig(config)).to.be.false; -// }); - -// it('should return true when arg is correct', () => { -// const config = { -// project: 'test', -// exclude: ['abc'], -// entryPoints: ['abc'], -// clean: true, -// }; - -// expect(isBuilderConfig(config)).to.be.true; -// }); -// }); -// }); diff --git a/src/lib/models/config.ts b/src/lib/models/config.ts index dca40b2..0133869 100644 --- a/src/lib/models/config.ts +++ b/src/lib/models/config.ts @@ -4,14 +4,22 @@ import { z } from 'zod'; const BuilderLogLevel = z.enum(['verbose', 'info', 'warn', 'error', 'off']); +const EsbuildOptions = z.any(); + +const AdvancedBuilderOptions = z.object({ + enableDirnameShim: z.boolean(), +}); + export const BuilderConfig = z.object({ project: z.string(), entryPoints: z.array(z.string()).optional(), exclude: z.array(z.string()).optional(), - esbuildOptions: z.any().optional(), + esbuildOptions: EsbuildOptions.optional(), clean: z.boolean().optional(), logLevel: BuilderLogLevel.optional(), + advancedOptions: AdvancedBuilderOptions.optional(), }); export type BuilderConfigType = z.infer; export type BuilderLogLevelType = z.infer; +export type AdvancedBuilderOptionsType = z.infer; diff --git a/src/lib/plugins/dirname.spec.ts b/src/lib/plugins/dirname.spec.ts new file mode 100644 index 0000000..3c690bb --- /dev/null +++ b/src/lib/plugins/dirname.spec.ts @@ -0,0 +1,80 @@ +import { expect } from 'chai'; +import { PluginBuild } from 'esbuild'; +import fs from 'fs/promises'; +import mockFS from 'mock-fs'; +import sinon from 'sinon'; +import { dirnamePlugin } from './dirname'; + +const dirnameShim = ` +import __import_PATH from 'path'; +import __import_URL from 'url' + +const __dirname = __import_PATH.dirname(__import_URL.fileURLToPath(import.meta.url)); +const __filename = __import_URL.fileURLToPath(import.meta.url);\n`; + +describe('DirnamePlugin', () => { + let sandbox: sinon.SinonSandbox; + + let mockBuild: { onStart: sinon.SinonStub; onEnd: sinon.SinonStub }; + + const output1 = 'some/output1.js'; + const output2 = 'some/output2.js'; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + mockFS({ + [output1]: mockFS.file({ content: '' }), + [output2]: mockFS.file({ content: '' }), + }); + + mockBuild = { + onStart: sandbox.stub(), + onEnd: sandbox.stub(), + }; + }); + + afterEach(() => { + sandbox.restore(); + mockFS.restore(); + }); + + it('should insert dirname shim at the top of every output', async () => { + const args = { + metafile: { + outputs: { + [output1]: {}, + [output2]: {}, + }, + }, + }; + + dirnamePlugin().setup({ ...mockBuild, initialOptions: { metafile: true } } as unknown as PluginBuild); + + mockBuild.onStart.firstCall.args[0](); + await mockBuild.onEnd.firstCall.args[0](args); + + const finalContent1 = await fs.readFile(output1, 'utf8'); + const finalContent2 = await fs.readFile(output2, 'utf8'); + + expect(finalContent1).to.eql(dirnameShim); + expect(finalContent2).to.eql(dirnameShim); + }); + + it('should return error in onStart when metafile is false', async () => { + const args = { + metafile: { + outputs: { + [output1]: {}, + [output2]: {}, + }, + }, + }; + + dirnamePlugin().setup({ ...mockBuild, initialOptions: { metafile: false } } as unknown as PluginBuild); + + const result = mockBuild.onStart.firstCall.args[0](); + + expect(result.errors).to.eql([{ text: 'Metafile must be enabled.' }]); + }); +}); diff --git a/src/lib/plugins/dirname.ts b/src/lib/plugins/dirname.ts index 8b76d1a..177e3e2 100644 --- a/src/lib/plugins/dirname.ts +++ b/src/lib/plugins/dirname.ts @@ -1 +1,39 @@ -export const dirnamePlugin = () => {}; +import { PluginBuild, Plugin, OnStartResult } from 'esbuild'; +import fs from 'fs/promises'; + +const dirnameShim = ` +import __import_PATH from 'path'; +import __import_URL from 'url' + +const __dirname = __import_PATH.dirname(__import_URL.fileURLToPath(import.meta.url)); +const __filename = __import_URL.fileURLToPath(import.meta.url);\n`; + +/** + * This plugin shims `__dirname` and `__filename` because they are not available in ESM. + * It does this using `import.meta.url`. + * + * **Requires `metafile` to be enabled in the esbuild options.** + * + * @see https://nodejs.org/docs/latest/api/esm.html#no-__filename-or-__dirname + * @returns The `esbuild` plugin. + */ +export const dirnamePlugin = (): Plugin => ({ + name: 'dirname', + setup: (build: PluginBuild) => { + build.onStart((): OnStartResult => { + if (!build.initialOptions.metafile) { + return { errors: [{ text: 'Metafile must be enabled.' }] }; + } + + return {}; + }); + + build.onEnd(async args => { + for (const output of Object.keys(args.metafile!.outputs)) { + const initialContent = await fs.readFile(output, 'utf8'); + const modifiedContent = [dirnameShim, initialContent].join(''); + await fs.writeFile(output, modifiedContent); + } + }); + }, +}); diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts new file mode 100644 index 0000000..3dfddbc --- /dev/null +++ b/src/lib/plugins/index.ts @@ -0,0 +1 @@ +export * from './dirname'; From febecf519b6f5cdbfe3d2f5fd3384e641c13bb12 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 18 Feb 2022 22:56:18 +0100 Subject: [PATCH 5/8] chore(docs): Update readme --- README.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b792e79..4ffe2da 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This tool is designed to work with Azure Functions written in TypeScript. It uses [esbuild](https://esbuild.github.io/) to create crazy small bundles. This is especially helpful with cold starts and deployment duration. +***Please read this readme to get started. It contains important information.*** + # Table of Contents - [Build](#build) - [From the CLI](#from-the-cli) @@ -20,6 +22,11 @@ This tool is designed to work with Azure Functions written in TypeScript. It use - [`clean`](#clean) - [`logLevel`](#loglevel) - [`esbuildOptions`](#esbuildoptions) + - [`advancedOptions`](#advancedoptions) + - [`enableDirnameShim`](#enabledirnameshim) +- [Common errors](#common-errors) + - [`ReferenceError: [__dirname|__filename] is not defined in ES module scope`](#referenceerror-__dirname__filename-is-not-defined-in-es-module-scope) + - [`Error: Dynamic require of "xyz" is not supported`](#error-dynamic-require-of-xyz-is-not-supported) - [Benchmark](#benchmark) - [Package size](#package-size) - [Build time](#build-time) @@ -98,6 +105,8 @@ main(); ## Config +**Important: By default, the file extension of output files is set to `.mjs`. This is because the Azure Functions runtime requires this [see Microsoft Docs](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=v2-v3-v4-export%2Cv2-v3-v4-done%2Cv2%2Cv2-log-custom-telemetry%2Cv2-accessing-request-and-response%2Cwindows-setting-the-node-version#ecmascript-modules). You need to change the `scriptFile` property of your *function.json* files accordingly.** + A simple starting config could look like this ```json { @@ -114,36 +123,36 @@ A simple starting config could look like this **Type:** `string` **Description:** The root folder of the Azure Functions project you want to build. **Example:** `.` -**Default:**: `undefined` +**Default:** `undefined` ### `entryPoints` **Required:** no **Type:** `string[]` **Description:** Specify custom entry points if you don't want *esbuild-azure-functions* to search for **index.ts** files in the `project` folder. -**Example:**: `[ "my-functions/entry.ts" ]` -**Default:**: `undefined` +**Example:** `[ "my-functions/entry.ts" ]` +**Default:** `undefined` ### `exclude` **Required:** no **Type:** `string[]` **Description:** Specify directories as glob patterns to exclude when searching for **index.ts** files. -**Example:**: `[ "**/utils/**" ]` -**Default:**: `undefined` +**Example:** `[ "**/utils/**" ]` +**Default:** `undefined` ### `clean` **Required:** no **Type:** `boolean` **Description:** Specify whether *esbuild-azure-functions* should the delete the output directory before building. -**Default:**: `false` +**Default:** `false` ### `logLevel` **Required:** no **Type:** `"off" | "error" | "warn" | "info" | "verbose"` **Description:** Specify the verbosity of log messages. -**Default:**: `"error"` +**Default:** `"error"` ### `esbuildOptions` @@ -165,6 +174,66 @@ A simple starting config could look like this } ``` +### `advancedOptions` +**Required:** no +**Type:** `object` +**Description:** Enable some advanced options depending on your environment + +#### `enableDirnameShim` +**Required:** no +**Type:** `boolean` +**Description:** Enables a plugin that patches `__dirname` and `__filename` using `import.meta.url` ([see official Node.js docs](https://nodejs.org/docs/latest/api/esm.html#no-__filename-or-__dirname)) at the top of every output file because they are not available in ESM and esbuild doesn't shim them itself. + +## Common errors + +### `ReferenceError: [__dirname|__filename] is not defined in ES module scope` + +This error stems from the fact that `__dirname` and `__filename` are not present in an ESM environment. To fix this, simply set `advancedOptions.enableDirnameShim` to `true` [see config](#enabledirnameshim) + +### `Error: Dynamic require of "xyz" is not supported` + +This error stems from esbuild not being able to convert CJS requires to ESM imports. This happens mostly (from what I've seen) with Node.js internals (like http, crypto and so on). To fix this issue you have two options: +1. Turn code splitting of and change the format to `cjs` **(not recommended because it increases the bundle size exponentially)** + +```js +// build.mjs + +await build({ + project: '.', + clean: true, + esbuildOptions: { + splitting: false, + format: 'cjs', + outExtension: {} + }, +}); +``` + +2. Use `@esbuild-plugins/esm-externals` with the following setup: + +```js +// build.mjs + +import { EsmExternalsPlugin } from '@esbuild-plugins/esm-externals'; +import { build } from 'esbuild-azure-functions'; +import repl from 'repl'; + +await build({ + project: '.', + exclude: ['**/utils/**'], + clean: true, + logLevel: 'verbose', + esbuildOptions: { + target: 'node12', + plugins: [ + EsmExternalsPlugin({ externals: [...repl._builtinLibs] }) + ], + }, +}); +``` + +If there are other modules causing issues for you, just add them to the `externals` options of `EsmExternalsPlugin`. + ## Benchmark ### Package size From 1c46fe800c8a16842e64303a9a3de67a02a3259f Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 18 Feb 2022 23:01:29 +0100 Subject: [PATCH 6/8] chore(docs): Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ffe2da..f18dc6e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > A builder for Azure Function powered by esbuild. -[![Continuous Integration Workflow](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml/badge.svg)](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml) [![Codecov](https://img.shields.io/codecov/c/github/beyerleinf/esbuild-azure-functions)](https://app.codecov.io/gh/beyerleinf/esbuild-azure-functions) [![npm](https://img.shields.io/npm/v/esbuild-azue-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![npm](https://img.shields.io/npm/dm/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![GitHub](https://img.shields.io/github/license/beyerleinf/esbuild-azure-functions)](https://github.com/beyerleinf/esbuild-azure-functions/blob/main/LICENSE) +[![Continuous Integration Workflow](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml/badge.svg)](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml) [![Codecov](https://img.shields.io/codecov/c/github/beyerleinf/esbuild-azure-functions)](https://app.codecov.io/gh/beyerleinf/esbuild-azure-functions) [![npm](https://img.shields.io/npm/v/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![npm](https://img.shields.io/npm/dm/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![GitHub](https://img.shields.io/github/license/beyerleinf/esbuild-azure-functions)](https://github.com/beyerleinf/esbuild-azure-functions/blob/main/LICENSE) This tool is designed to work with Azure Functions written in TypeScript. It uses [esbuild](https://esbuild.github.io/) to create crazy small bundles. This is especially helpful with cold starts and deployment duration. From c4c7274ce88b4201191d3f12a6a1e3bb0b978b86 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 18 Feb 2022 23:22:43 +0100 Subject: [PATCH 7/8] chore: Fix linter errors --- src/lib/plugins/dirname.spec.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/lib/plugins/dirname.spec.ts b/src/lib/plugins/dirname.spec.ts index 3c690bb..e1aba57 100644 --- a/src/lib/plugins/dirname.spec.ts +++ b/src/lib/plugins/dirname.spec.ts @@ -62,15 +62,6 @@ describe('DirnamePlugin', () => { }); it('should return error in onStart when metafile is false', async () => { - const args = { - metafile: { - outputs: { - [output1]: {}, - [output2]: {}, - }, - }, - }; - dirnamePlugin().setup({ ...mockBuild, initialOptions: { metafile: false } } as unknown as PluginBuild); const result = mockBuild.onStart.firstCall.args[0](); From 7158cf1c335646e34a1695f695af162e3b318feb Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 18 Feb 2022 23:25:19 +0100 Subject: [PATCH 8/8] chore(docs): Add snyk badge to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f18dc6e..cac43ce 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > A builder for Azure Function powered by esbuild. -[![Continuous Integration Workflow](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml/badge.svg)](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml) [![Codecov](https://img.shields.io/codecov/c/github/beyerleinf/esbuild-azure-functions)](https://app.codecov.io/gh/beyerleinf/esbuild-azure-functions) [![npm](https://img.shields.io/npm/v/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![npm](https://img.shields.io/npm/dm/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![GitHub](https://img.shields.io/github/license/beyerleinf/esbuild-azure-functions)](https://github.com/beyerleinf/esbuild-azure-functions/blob/main/LICENSE) +[![Continuous Integration Workflow](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml/badge.svg)](https://github.com/beyerleinf/esbuild-azure-functions/actions/workflows/ci.yml) [![Snyk Vulnerabilities for GitHub Repo](https://img.shields.io/snyk/vulnerabilities/github/beyerleinf/esbuild-azure-functions)](https://github.com/beyerleinf/esbuild-azure-functions) [![Codecov](https://img.shields.io/codecov/c/github/beyerleinf/esbuild-azure-functions)](https://app.codecov.io/gh/beyerleinf/esbuild-azure-functions) [![npm](https://img.shields.io/npm/v/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![npm](https://img.shields.io/npm/dm/esbuild-azure-functions)](https://www.npmjs.com/package/esbuild-azure-functions) [![GitHub](https://img.shields.io/github/license/beyerleinf/esbuild-azure-functions)](https://github.com/beyerleinf/esbuild-azure-functions/blob/main/LICENSE) This tool is designed to work with Azure Functions written in TypeScript. It uses [esbuild](https://esbuild.github.io/) to create crazy small bundles. This is especially helpful with cold starts and deployment duration.