diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb4160532b..e824238203e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ Keep in mind that the [`"browser"` field](https://github.com/defunctzombie/package-browser-field-spec) still takes precedence over both `"module"` and `"main"`. +* Additional validation of arguments to JavaScript API calls ([#381](https://github.com/evanw/esbuild/issues/381)) + + JavaScript API calls each take an object with many optional properties as an argument. Previously there was only minimal validation of the contents of that object. If you aren't using TypeScript, this can lead to confusing situations when the data on the object is invalid. Now there is some additional validation done to the shape of the object and the types of the properties. + + It is now an error to pass an object with a property that esbuild won't use. This should help to catch typos. It is also now an error if a property on the object has an unexpected type. + ## 0.6.34 * Fix parsing of `type;` statements followed by an identifier in TypeScript ([#377](https://github.com/evanw/esbuild/pull/377)) diff --git a/lib/common.ts b/lib/common.ts index 7aabf0753ba..95a4eab9547 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -7,92 +7,181 @@ function validateTarget(target: string): string { return target } -function pushCommonFlags(flags: string[], options: types.CommonOptions, isTTY: boolean, logLevelDefault: types.LogLevel): void { - if (options.target) { - if (options.target instanceof Array) flags.push(`--target=${Array.from(options.target).map(validateTarget).join(',')}`) - else flags.push(`--target=${validateTarget(options.target)}`) +let mustBeBoolean = (value: boolean | undefined): string | null => + typeof value === 'boolean' ? null : 'a boolean'; + +let mustBeString = (value: string | undefined): string | null => + typeof value === 'string' ? null : 'a string'; + +let mustBeInteger = (value: number | undefined): string | null => + typeof value === 'number' && value === (value | 0) ? null : 'an integer'; + +let mustBeArray = (value: string[] | undefined): string | null => + Array.isArray(value) ? null : 'an array'; + +let mustBeObject = (value: Object | undefined): string | null => + typeof value === 'object' && value !== null && !Array.isArray(value) ? null : 'an object'; + +let mustBeStringOrBoolean = (value: string | boolean | undefined): string | null => + typeof value === 'string' || typeof value === 'boolean' ? null : 'a string or a boolean'; + +let mustBeStringOrArray = (value: string | string[] | undefined): string | null => + typeof value === 'string' || Array.isArray(value) ? null : 'a string or an array'; + +let mustBeBooleanOrArray = (value: boolean | string[] | undefined): string | null => + typeof value === 'boolean' || Array.isArray(value) ? null : 'a boolean or an array'; + +type OptionKeys = { [key: string]: boolean }; + +function getFlag(object: T, keys: OptionKeys, key: K, mustBeFn: (value: T[K]) => string | null): T[K] | undefined { + let value = object[key]; + if (value === undefined) return undefined; + let mustBe = mustBeFn(value); + if (mustBe !== null) throw new Error(`"${key}" must be ${mustBe}`); + keys[key + ''] = true; + return value; +} + +function checkForInvalidFlags(object: Object, keys: OptionKeys): void { + for (let key in object) { + if (!(key in keys)) { + throw new Error(`Invalid option: "${key}"`); + } + } +} + +function pushCommonFlags(flags: string[], options: types.CommonOptions, keys: OptionKeys, isTTY: boolean, logLevelDefault: types.LogLevel): void { + let target = getFlag(options, keys, 'target', mustBeStringOrArray); + let format = getFlag(options, keys, 'format', mustBeString); + let globalName = getFlag(options, keys, 'globalName', mustBeString); + let strict = getFlag(options, keys, 'strict', mustBeBooleanOrArray); + let minify = getFlag(options, keys, 'minify', mustBeBoolean); + let minifySyntax = getFlag(options, keys, 'minifySyntax', mustBeBoolean); + let minifyWhitespace = getFlag(options, keys, 'minifyWhitespace', mustBeBoolean); + let minifyIdentifiers = getFlag(options, keys, 'minifyIdentifiers', mustBeBoolean); + let jsxFactory = getFlag(options, keys, 'jsxFactory', mustBeString); + let jsxFragment = getFlag(options, keys, 'jsxFragment', mustBeString); + let define = getFlag(options, keys, 'define', mustBeObject); + let pure = getFlag(options, keys, 'pure', mustBeArray); + let color = getFlag(options, keys, 'color', mustBeBoolean); + let logLevel = getFlag(options, keys, 'logLevel', mustBeString); + let errorLimit = getFlag(options, keys, 'errorLimit', mustBeInteger); + + if (target) { + if (Array.isArray(target)) flags.push(`--target=${Array.from(target).map(validateTarget).join(',')}`) + else flags.push(`--target=${validateTarget(target)}`) } - if (options.format) flags.push(`--format=${options.format}`); - if (options.globalName) flags.push(`--global-name=${options.globalName}`); - if (options.strict === true) flags.push(`--strict`); - else if (options.strict) for (let key of options.strict) flags.push(`--strict:${key}`); - - if (options.minify) flags.push('--minify'); - if (options.minifySyntax) flags.push('--minify-syntax'); - if (options.minifyWhitespace) flags.push('--minify-whitespace'); - if (options.minifyIdentifiers) flags.push('--minify-identifiers'); - - if (options.jsxFactory) flags.push(`--jsx-factory=${options.jsxFactory}`); - if (options.jsxFragment) flags.push(`--jsx-fragment=${options.jsxFragment}`); - if (options.define) { - for (let key in options.define) { + if (format) flags.push(`--format=${format}`); + if (globalName) flags.push(`--global-name=${globalName}`); + if (strict === true) flags.push(`--strict`); + else if (strict) for (let key of strict) flags.push(`--strict:${key}`); + + if (minify) flags.push('--minify'); + if (minifySyntax) flags.push('--minify-syntax'); + if (minifyWhitespace) flags.push('--minify-whitespace'); + if (minifyIdentifiers) flags.push('--minify-identifiers'); + + if (jsxFactory) flags.push(`--jsx-factory=${jsxFactory}`); + if (jsxFragment) flags.push(`--jsx-fragment=${jsxFragment}`); + if (define) { + for (let key in define) { if (key.indexOf('=') >= 0) throw new Error(`Invalid define: ${key}`); - flags.push(`--define:${key}=${options.define[key]}`); + flags.push(`--define:${key}=${define[key]}`); } } - if (options.pure) for (let fn of options.pure) flags.push(`--pure:${fn}`); + if (pure) for (let fn of pure) flags.push(`--pure:${fn}`); - if (options.color) flags.push(`--color=${options.color}`); + if (color) flags.push(`--color=${color}`); else if (isTTY) flags.push(`--color=true`); // This is needed to fix "execFileSync" which buffers stderr - flags.push(`--log-level=${options.logLevel || logLevelDefault}`); - flags.push(`--error-limit=${options.errorLimit || 0}`); + flags.push(`--log-level=${logLevel || logLevelDefault}`); + flags.push(`--error-limit=${errorLimit || 0}`); } -function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean): [string[], string | null, string | null] { +function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean): [string[], boolean, string | null, string | null] { let flags: string[] = []; + let keys: OptionKeys = Object.create(null); let stdinContents: string | null = null; let stdinResolveDir: string | null = null; - pushCommonFlags(flags, options, isTTY, 'info'); - - if (options.sourcemap) flags.push(`--sourcemap${options.sourcemap === true ? '' : `=${options.sourcemap}`}`); - if (options.bundle) flags.push('--bundle'); - if (options.splitting) flags.push('--splitting'); - if (options.metafile) flags.push(`--metafile=${options.metafile}`); - if (options.outfile) flags.push(`--outfile=${options.outfile}`); - if (options.outdir) flags.push(`--outdir=${options.outdir}`); - if (options.platform) flags.push(`--platform=${options.platform}`); - if (options.tsconfig) flags.push(`--tsconfig=${options.tsconfig}`); - if (options.resolveExtensions) flags.push(`--resolve-extensions=${options.resolveExtensions.join(',')}`); - if (options.external) for (let name of options.external) flags.push(`--external:${name}`); - if (options.loader) { - for (let ext in options.loader) { + pushCommonFlags(flags, options, keys, isTTY, 'info'); + + let sourcemap = getFlag(options, keys, 'sourcemap', mustBeStringOrBoolean); + let bundle = getFlag(options, keys, 'bundle', mustBeBoolean); + let splitting = getFlag(options, keys, 'splitting', mustBeBoolean); + let metafile = getFlag(options, keys, 'metafile', mustBeString); + let outfile = getFlag(options, keys, 'outfile', mustBeString); + let outdir = getFlag(options, keys, 'outdir', mustBeString); + let platform = getFlag(options, keys, 'platform', mustBeString); + let tsconfig = getFlag(options, keys, 'tsconfig', mustBeString); + let resolveExtensions = getFlag(options, keys, 'resolveExtensions', mustBeArray); + let external = getFlag(options, keys, 'external', mustBeArray); + let loader = getFlag(options, keys, 'loader', mustBeObject); + let outExtension = getFlag(options, keys, 'outExtension', mustBeObject); + let entryPoints = getFlag(options, keys, 'entryPoints', mustBeArray); + let stdin = getFlag(options, keys, 'stdin', mustBeObject); + let write = getFlag(options, keys, 'write', mustBeBoolean) !== false; + checkForInvalidFlags(options, keys); + + if (sourcemap) flags.push(`--sourcemap${sourcemap === true ? '' : `=${sourcemap}`}`); + if (bundle) flags.push('--bundle'); + if (splitting) flags.push('--splitting'); + if (metafile) flags.push(`--metafile=${metafile}`); + if (outfile) flags.push(`--outfile=${outfile}`); + if (outdir) flags.push(`--outdir=${outdir}`); + if (platform) flags.push(`--platform=${platform}`); + if (tsconfig) flags.push(`--tsconfig=${tsconfig}`); + if (resolveExtensions) flags.push(`--resolve-extensions=${resolveExtensions.join(',')}`); + if (external) for (let name of external) flags.push(`--external:${name}`); + if (loader) { + for (let ext in loader) { if (ext.indexOf('=') >= 0) throw new Error(`Invalid extension: ${ext}`); - flags.push(`--loader:${ext}=${options.loader[ext]}`); + flags.push(`--loader:${ext}=${loader[ext]}`); } } - if (options.outExtension) { - for (let ext in options.outExtension) { + if (outExtension) { + for (let ext in outExtension) { if (ext.indexOf('=') >= 0) throw new Error(`Invalid extension: ${ext}`); - flags.push(`--out-extension:${ext}=${options.outExtension[ext]}`); + flags.push(`--out-extension:${ext}=${outExtension[ext]}`); } } - if (options.entryPoints) { - for (let entryPoint of options.entryPoints) { + if (entryPoints) { + for (let entryPoint of entryPoints) { entryPoint += ''; if (entryPoint.startsWith('-')) throw new Error(`Invalid entry point: ${entryPoint}`); flags.push(entryPoint); } } - if (options.stdin) { - let { contents, resolveDir, sourcefile, loader } = options.stdin; + if (stdin) { + let stdinKeys: OptionKeys = Object.create(null); + let contents = getFlag(stdin, stdinKeys, 'contents', mustBeString); + let resolveDir = getFlag(stdin, stdinKeys, 'resolveDir', mustBeString); + let sourcefile = getFlag(stdin, stdinKeys, 'sourcefile', mustBeString); + let loader = getFlag(stdin, stdinKeys, 'loader', mustBeString); + checkForInvalidFlags(stdin, stdinKeys); + if (sourcefile) flags.push(`--sourcefile=${sourcefile}`); if (loader) flags.push(`--loader=${loader}`); if (resolveDir) stdinResolveDir = resolveDir + ''; stdinContents = contents ? contents + '' : ''; } - return [flags, stdinContents, stdinResolveDir]; + return [flags, write, stdinContents, stdinResolveDir]; } function flagsForTransformOptions(options: types.TransformOptions, isTTY: boolean): string[] { let flags: string[] = []; - pushCommonFlags(flags, options, isTTY, 'silent'); + let keys: OptionKeys = Object.create(null); + pushCommonFlags(flags, options, keys, isTTY, 'silent'); + + let sourcemap = getFlag(options, keys, 'sourcemap', mustBeStringOrBoolean); + let sourcefile = getFlag(options, keys, 'sourcefile', mustBeString); + let loader = getFlag(options, keys, 'loader', mustBeString); + checkForInvalidFlags(options, keys); - if (options.sourcemap) flags.push(`--sourcemap=${options.sourcemap === true ? 'external' : options.sourcemap}`); - if (options.sourcefile) flags.push(`--sourcefile=${options.sourcefile}`); - if (options.loader) flags.push(`--loader=${options.loader}`); + if (sourcemap) flags.push(`--sourcemap=${sourcemap === true ? 'external' : sourcemap}`); + if (sourcefile) flags.push(`--sourcefile=${sourcefile}`); + if (loader) flags.push(`--loader=${loader}`); return flags; } @@ -221,9 +310,8 @@ export function createChannel(streamIn: StreamIn): StreamOut { service: { build(options, isTTY, callback) { - let [flags, stdin, resolveDir] = flagsForBuildOptions(options, isTTY); + let [flags, write, stdin, resolveDir] = flagsForBuildOptions(options, isTTY); try { - let write = options.write !== false; let request: protocol.BuildRequest = { command: 'build', flags, write, stdin, resolveDir }; sendRequest(request, (error, response) => { if (error) return callback(new Error(error), null); diff --git a/lib/types.ts b/lib/types.ts index 2953675530c..3524c782f26 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -4,7 +4,7 @@ export type Loader = 'js' | 'jsx' | 'ts' | 'tsx' | 'json' | 'text' | 'base64' | export type LogLevel = 'info' | 'warning' | 'error' | 'silent'; export type Strict = 'nullish-coalescing' | 'optional-chaining' | 'class-fields'; -export interface CommonOptions { +interface CommonOptions { sourcemap?: boolean | 'inline' | 'external'; format?: Format; globalName?: string; diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index aeeb4e8fb93..961891f174f 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -14,6 +14,17 @@ const repoDir = path.dirname(__dirname) const rootTestDir = path.join(repoDir, 'scripts', '.js-api-tests') let buildTests = { + async errorIfEntryPointsNotArray({ esbuild }) { + try { + await esbuild.build({ entryPoints: 'this is not an array' }) + throw new Error('Expected build failure'); + } catch (e) { + if (e.message !== '"entryPoints" must be an array') { + throw e; + } + } + }, + async es6_to_cjs({ esbuild, testDir }) { const input = path.join(testDir, 'in.js') const output = path.join(testDir, 'out.js')