diff --git a/src/config.ts b/src/config.ts index e307d6763..4c03184b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,8 @@ import mergeOptions from 'merge-options' import minimatch from 'minimatch' import { FunctionSource } from './function' -import type { NodeBundlerName, NodeVersion } from './runtimes/node' +import type { NodeVersionString } from './runtimes/node' +import type { NodeBundlerName } from './runtimes/node/bundlers' interface FunctionConfig { externalNodeModules?: string[] @@ -11,7 +12,7 @@ interface FunctionConfig { ignoredNodeModules?: string[] nodeBundler?: NodeBundlerName nodeSourcemap?: boolean - nodeVersion?: NodeVersion + nodeVersion?: NodeVersionString processDynamicNodeImports?: boolean rustTargetDirectory?: string schedule?: string diff --git a/src/feature_flags.ts b/src/feature_flags.ts index 1e06e282c..8ff5a514b 100644 --- a/src/feature_flags.ts +++ b/src/feature_flags.ts @@ -6,6 +6,7 @@ const FLAGS: Record = { defaultEsModulesToEsbuild: Boolean(env.NETLIFY_EXPERIMENTAL_DEFAULT_ES_MODULES_TO_ESBUILD), parseWithEsbuild: false, traceWithNft: false, + zisi_pure_esm: false, } type FeatureFlag = keyof typeof FLAGS diff --git a/src/runtimes/node/bundlers/esbuild/bundler.ts b/src/runtimes/node/bundlers/esbuild/bundler.ts index 2f4e319dd..097a727df 100644 --- a/src/runtimes/node/bundlers/esbuild/bundler.ts +++ b/src/runtimes/node/bundlers/esbuild/bundler.ts @@ -3,7 +3,7 @@ import { basename, dirname, extname, resolve, join } from 'path' import { build, Metafile } from '@netlify/esbuild' import { tmpName } from 'tmp-promise' -import type { NodeBundlerName } from '../..' +import type { NodeBundlerName } from '..' import type { FunctionConfig } from '../../../../config' import { getPathWithExtension, safeUnlink } from '../../../../utils/fs' import type { RuntimeName } from '../../../runtime' diff --git a/src/runtimes/node/bundlers/esbuild/index.ts b/src/runtimes/node/bundlers/esbuild/index.ts index 6bc832383..4314c8259 100644 --- a/src/runtimes/node/bundlers/esbuild/index.ts +++ b/src/runtimes/node/bundlers/esbuild/index.ts @@ -122,6 +122,7 @@ const bundle: BundleFunction = async ({ bundlerWarnings, inputs, mainFile: normalizedMainFile, + moduleFormat: 'cjs', nativeNodeModules, nodeModulesWithDynamicImports, srcFiles: [...supportingSrcFiles, ...bundlePaths.keys()], diff --git a/src/runtimes/node/bundlers/index.ts b/src/runtimes/node/bundlers/index.ts index 3acce5879..7cdbd5e07 100644 --- a/src/runtimes/node/bundlers/index.ts +++ b/src/runtimes/node/bundlers/index.ts @@ -1,14 +1,17 @@ import type { Message } from '@netlify/esbuild' -import type { NodeBundlerName } from '..' import { FunctionConfig } from '../../../config' import { FeatureFlag, FeatureFlags } from '../../../feature_flags' import { FunctionSource } from '../../../function' +import { detectEsModule } from '../utils/detect_es_module' +import { ModuleFormat } from '../utils/module_format' import esbuildBundler from './esbuild' import nftBundler from './nft' import zisiBundler from './zisi' +export type NodeBundlerName = 'esbuild' | 'esbuild_zisi' | 'nft' | 'zisi' + // TODO: Create a generic warning type type BundlerWarning = Message @@ -49,6 +52,7 @@ type BundleFunction = ( cleanupFunction?: CleanupFunction inputs: string[] mainFile: string + moduleFormat: ModuleFormat nativeNodeModules?: NativeNodeModules nodeModulesWithDynamicImports?: string[] srcFiles: string[] @@ -86,5 +90,37 @@ const getBundler = (name: NodeBundlerName): NodeBundler => { } } -export { getBundler } +// We use ZISI as the default bundler, except for certain extensions, for which +// esbuild is the only option. +const getDefaultBundler = async ({ + extension, + mainFile, + featureFlags, +}: { + extension: string + mainFile: string + featureFlags: FeatureFlags +}): Promise => { + const { defaultEsModulesToEsbuild, traceWithNft } = featureFlags + + if (['.mjs', '.ts'].includes(extension)) { + return 'esbuild' + } + + if (traceWithNft) { + return 'nft' + } + + if (defaultEsModulesToEsbuild) { + const isEsModule = await detectEsModule({ mainFile }) + + if (isEsModule) { + return 'esbuild' + } + } + + return 'zisi' +} + +export { getBundler, getDefaultBundler } export type { BundleFunction, GetSrcFilesFunction, NativeNodeModules } diff --git a/src/runtimes/node/bundlers/nft/es_modules.ts b/src/runtimes/node/bundlers/nft/es_modules.ts index 304c444c5..461fdd243 100644 --- a/src/runtimes/node/bundlers/nft/es_modules.ts +++ b/src/runtimes/node/bundlers/nft/es_modules.ts @@ -1,10 +1,13 @@ -import { basename, resolve } from 'path' +import { basename, dirname, resolve } from 'path' import { NodeFileTraceReasons } from '@vercel/nft' import type { FunctionConfig } from '../../../../config' +import { FeatureFlags } from '../../../../feature_flags' import { cachedReadFile, FsCache } from '../../../../utils/fs' -import { PackageJson } from '../../utils/package_json' +import { ModuleFormat } from '../../utils/module_format' +import { getNodeSupportMatrix } from '../../utils/node_version' +import { getPackageJson, PackageJson } from '../../utils/package_json' import { transpile } from './transpile' @@ -19,6 +22,21 @@ const getPatchedESMPackages = async (packages: string[], fsCache: FsCache) => { return patchedPackagesMap } +const isEntrypointESM = ({ + basePath, + esmPaths, + mainFile, +}: { + basePath?: string + esmPaths: Set + mainFile: string +}) => { + const absoluteESMPaths = new Set([...esmPaths].map((path) => resolvePath(path, basePath))) + const entrypointIsESM = absoluteESMPaths.has(mainFile) + + return entrypointIsESM +} + const patchESMPackage = async (path: string, fsCache: FsCache) => { const file = (await cachedReadFile(fsCache, path, 'utf8')) as string const packageJson: PackageJson = JSON.parse(file) @@ -30,6 +48,51 @@ const patchESMPackage = async (path: string, fsCache: FsCache) => { return JSON.stringify(patchedPackageJson) } +const processESM = async ({ + basePath, + config, + esmPaths, + featureFlags, + fsCache, + mainFile, + reasons, +}: { + basePath: string | undefined + config: FunctionConfig + esmPaths: Set + featureFlags: FeatureFlags + fsCache: FsCache + mainFile: string + reasons: NodeFileTraceReasons +}): Promise<{ rewrites?: Map; moduleFormat: ModuleFormat }> => { + const entrypointIsESM = isEntrypointESM({ basePath, esmPaths, mainFile }) + + if (!entrypointIsESM) { + return { + moduleFormat: 'cjs', + } + } + + const packageJson = await getPackageJson(dirname(mainFile)) + const nodeSupport = getNodeSupportMatrix(config.nodeVersion) + + if (featureFlags.zisi_pure_esm && packageJson.type === 'module' && nodeSupport.esm) { + return { + moduleFormat: 'esm', + } + } + + const rewrites = await transpileESM({ basePath, config, esmPaths, fsCache, reasons }) + + return { + moduleFormat: 'cjs', + rewrites, + } +} + +const resolvePath = (relativePath: string, basePath?: string) => + basePath ? resolve(basePath, relativePath) : resolve(relativePath) + const shouldTranspile = ( path: string, cache: Map, @@ -101,7 +164,7 @@ const transpileESM = async ({ await Promise.all( pathsToTranspile.map(async (path) => { - const absolutePath = basePath ? resolve(basePath, path) : resolve(path) + const absolutePath = resolvePath(path, basePath) const transpiled = await transpile(absolutePath, config) rewrites.set(absolutePath, transpiled) @@ -111,4 +174,4 @@ const transpileESM = async ({ return rewrites } -export { transpileESM } +export { processESM } diff --git a/src/runtimes/node/bundlers/nft/index.ts b/src/runtimes/node/bundlers/nft/index.ts index 60c5e5acb..f45144987 100644 --- a/src/runtimes/node/bundlers/nft/index.ts +++ b/src/runtimes/node/bundlers/nft/index.ts @@ -7,12 +7,13 @@ import unixify from 'unixify' import type { BundleFunction } from '..' import type { FunctionConfig } from '../../../../config' +import { FeatureFlags } from '../../../../feature_flags' import { cachedReadFile, FsCache } from '../../../../utils/fs' import type { GetSrcFilesFunction } from '../../../runtime' import { getBasePath } from '../../utils/base_path' import { filterExcludedPaths, getPathsOfIncludedFiles } from '../../utils/included_files' -import { transpileESM } from './es_modules' +import { processESM } from './es_modules' // Paths that will be excluded from the tracing process. const ignore = ['node_modules/aws-sdk/**'] @@ -22,6 +23,7 @@ const appearsToBeModuleName = (name: string) => !name.startsWith('.') const bundle: BundleFunction = async ({ basePath, config, + featureFlags, mainFile, pluginsModulesPath, repositoryRoot = basePath, @@ -31,9 +33,14 @@ const bundle: BundleFunction = async ({ includedFiles, includedFilesBasePath || basePath, ) - const { paths: dependencyPaths, rewrites } = await traceFilesAndTranspile({ + const { + moduleFormat, + paths: dependencyPaths, + rewrites, + } = await traceFilesAndTranspile({ basePath: repositoryRoot, config, + featureFlags, mainFile, pluginsModulesPath, }) @@ -47,6 +54,7 @@ const bundle: BundleFunction = async ({ basePath: getBasePath(dirnames), inputs: dependencyPaths, mainFile, + moduleFormat, rewrites, srcFiles, } @@ -62,11 +70,13 @@ const ignoreFunction = (path: string) => { const traceFilesAndTranspile = async function ({ basePath, config, + featureFlags, mainFile, pluginsModulesPath, }: { basePath?: string config: FunctionConfig + featureFlags: FeatureFlags mainFile: string pluginsModulesPath?: string }) { @@ -111,9 +121,18 @@ const traceFilesAndTranspile = async function ({ const normalizedDependencyPaths = [...dependencyPaths].map((path) => basePath ? resolve(basePath, path) : resolve(path), ) - const rewrites = await transpileESM({ basePath, config, esmPaths: esmFileList, fsCache, reasons }) + const { moduleFormat, rewrites } = await processESM({ + basePath, + config, + esmPaths: esmFileList, + featureFlags, + fsCache, + mainFile, + reasons, + }) return { + moduleFormat, paths: normalizedDependencyPaths, rewrites, } diff --git a/src/runtimes/node/bundlers/zisi/index.ts b/src/runtimes/node/bundlers/zisi/index.ts index 2785344c1..85bebc2f4 100644 --- a/src/runtimes/node/bundlers/zisi/index.ts +++ b/src/runtimes/node/bundlers/zisi/index.ts @@ -42,6 +42,7 @@ const bundle: BundleFunction = async ({ basePath: getBasePath(dirnames), inputs: srcFiles, mainFile, + moduleFormat: 'cjs', srcFiles, } } diff --git a/src/runtimes/node/bundlers/zisi/list_imports.ts b/src/runtimes/node/bundlers/zisi/list_imports.ts index 0bf7dd67e..ab3807e0d 100644 --- a/src/runtimes/node/bundlers/zisi/list_imports.ts +++ b/src/runtimes/node/bundlers/zisi/list_imports.ts @@ -2,7 +2,7 @@ import * as esbuild from '@netlify/esbuild' import isBuiltinModule from 'is-builtin-module' import { tmpName } from 'tmp-promise' -import type { NodeBundlerName } from '../..' +import type { NodeBundlerName } from '..' import { safeUnlink } from '../../../../utils/fs' import type { RuntimeName } from '../../../runtime' diff --git a/src/runtimes/node/index.ts b/src/runtimes/node/index.ts index db47b951c..30d139930 100644 --- a/src/runtimes/node/index.ts +++ b/src/runtimes/node/index.ts @@ -2,50 +2,15 @@ import { join } from 'path' import cpFile from 'cp-file' -import { FeatureFlags } from '../../feature_flags' import { GetSrcFilesFunction, Runtime, ZipFunction } from '../runtime' -import { getBundler } from './bundlers' +import { getBundler, getDefaultBundler } from './bundlers' import { findFunctionsInPaths, findFunctionInPath } from './finder' import { findISCDeclarationsInPath } from './in_source_config' -import { detectEsModule } from './utils/detect_es_module' import { createAliases as createPluginsModulesPathAliases, getPluginsModulesPath } from './utils/plugin_modules_path' import { zipNodeJs } from './utils/zip' -export type NodeBundlerName = 'esbuild' | 'esbuild_zisi' | 'nft' | 'zisi' -export { NodeVersion } from './utils/node_version' - -// We use ZISI as the default bundler, except for certain extensions, for which -// esbuild is the only option. -const getDefaultBundler = async ({ - extension, - mainFile, - featureFlags, -}: { - extension: string - mainFile: string - featureFlags: FeatureFlags -}): Promise => { - const { defaultEsModulesToEsbuild, traceWithNft } = featureFlags - - if (['.mjs', '.ts'].includes(extension)) { - return 'esbuild' - } - - if (traceWithNft) { - return 'nft' - } - - if (defaultEsModulesToEsbuild) { - const isEsModule = await detectEsModule({ mainFile }) - - if (isEsModule) { - return 'esbuild' - } - } - - return 'zisi' -} +export { NodeVersionString } from './utils/node_version' // A proxy for the `getSrcFiles` function which adds a default `bundler` using // the `getDefaultBundler` function. @@ -98,6 +63,7 @@ const zipFunction: ZipFunction = async function ({ bundlerWarnings, inputs, mainFile: finalMainFile = mainFile, + moduleFormat, nativeNodeModules, nodeModulesWithDynamicImports, rewrites, @@ -130,6 +96,7 @@ const zipFunction: ZipFunction = async function ({ extension, filename, mainFile: finalMainFile, + moduleFormat, rewrites, srcFiles, }) diff --git a/src/runtimes/node/utils/entry_file.ts b/src/runtimes/node/utils/entry_file.ts new file mode 100644 index 000000000..464f0d412 --- /dev/null +++ b/src/runtimes/node/utils/entry_file.ts @@ -0,0 +1,45 @@ +import { basename, extname } from 'path' + +import type { ModuleFormat } from './module_format' +import { normalizeFilePath } from './normalize_path' + +interface EntryFile { + contents: string + filename: string +} + +const getEntryFileContents = (mainPath: string, moduleFormat: string) => { + const importPath = `.${mainPath.startsWith('/') ? mainPath : `/${mainPath}`}` + + if (moduleFormat === 'cjs') { + return `module.exports = require('${importPath}')` + } + + return `export { handler } from '${importPath}'` +} + +const getEntryFile = ({ + commonPrefix, + filename, + mainFile, + moduleFormat, + userNamespace, +}: { + commonPrefix: string + filename: string + mainFile: string + moduleFormat: ModuleFormat + userNamespace: string +}): EntryFile => { + const mainPath = normalizeFilePath({ commonPrefix, path: mainFile, userNamespace }) + const extension = extname(filename) + const entryFilename = `${basename(filename, extension)}.js` + const contents = getEntryFileContents(mainPath, moduleFormat) + + return { + contents, + filename: entryFilename, + } +} + +export { EntryFile, getEntryFile } diff --git a/src/runtimes/node/utils/module_format.ts b/src/runtimes/node/utils/module_format.ts new file mode 100644 index 000000000..ec75421ba --- /dev/null +++ b/src/runtimes/node/utils/module_format.ts @@ -0,0 +1 @@ +export type ModuleFormat = 'cjs' | 'esm' diff --git a/src/runtimes/node/utils/node_version.ts b/src/runtimes/node/utils/node_version.ts index 1f697378a..de0293a53 100644 --- a/src/runtimes/node/utils/node_version.ts +++ b/src/runtimes/node/utils/node_version.ts @@ -1,6 +1,10 @@ -// eslint-disable-next-line no-magic-numbers +/* eslint-disable no-magic-numbers */ type SupportedVersionNumbers = 8 | 10 | 12 | 14 -type NodeVersion = `${SupportedVersionNumbers}.x` | `nodejs${SupportedVersionNumbers}.x` +type NodeVersionString = `${SupportedVersionNumbers}.x` | `nodejs${SupportedVersionNumbers}.x` + +interface NodeVersionSupport { + esm: boolean +} // Must match the default version used in Bitballoon. const DEFAULT_NODE_VERSION = 14 @@ -8,6 +12,14 @@ const VERSION_REGEX = /(nodejs)?(\d+)\.x/ const getNodeVersion = (configVersion?: string) => parseVersion(configVersion) ?? DEFAULT_NODE_VERSION +const getNodeSupportMatrix = (configVersion?: string): NodeVersionSupport => { + const versionNumber = getNodeVersion(configVersion) + + return { + esm: versionNumber >= 14, + } +} + // Takes a string in the format defined by the `NodeVersion` type and returns // the numeric major version (e.g. "nodejs14.x" => 14). const parseVersion = (input: string | undefined) => { @@ -30,4 +42,12 @@ const parseVersion = (input: string | undefined) => { return version } -export { DEFAULT_NODE_VERSION, getNodeVersion, parseVersion, NodeVersion } +export { + DEFAULT_NODE_VERSION, + getNodeSupportMatrix, + getNodeVersion, + NodeVersionString, + NodeVersionSupport, + parseVersion, +} +/* eslint-enable no-magic-numbers */ diff --git a/src/runtimes/node/utils/normalize_path.ts b/src/runtimes/node/utils/normalize_path.ts new file mode 100644 index 000000000..c0e91dad0 --- /dev/null +++ b/src/runtimes/node/utils/normalize_path.ts @@ -0,0 +1,24 @@ +import { normalize, sep } from 'path' + +import unixify from 'unixify' + +// `adm-zip` and `require()` expect Unix paths. +// We remove the common path prefix. +// With files on different Windows drives, we remove the drive letter. +const normalizeFilePath = function ({ + commonPrefix, + path, + userNamespace, +}: { + commonPrefix: string + path: string + userNamespace: string +}) { + const userNamespacePathSegment = userNamespace ? `${userNamespace}${sep}` : '' + const pathA = normalize(path) + const pathB = pathA.replace(commonPrefix, userNamespacePathSegment) + const pathC = unixify(pathB) + return pathC +} + +export { normalizeFilePath } diff --git a/src/runtimes/node/utils/zip.ts b/src/runtimes/node/utils/zip.ts index 079ecd06a..4562a5721 100644 --- a/src/runtimes/node/utils/zip.ts +++ b/src/runtimes/node/utils/zip.ts @@ -1,17 +1,19 @@ -/* eslint-disable max-lines */ import { Buffer } from 'buffer' import { Stats, promises as fs } from 'fs' import os from 'os' -import { basename, extname, join, normalize, resolve, sep } from 'path' +import { basename, join, resolve } from 'path' import copyFile from 'cp-file' import deleteFiles from 'del' import pMap from 'p-map' -import unixify from 'unixify' import { startZip, addZipFile, addZipContent, endZip, ZipArchive } from '../../../archive' import { mkdirAndWriteFile } from '../../../utils/fs' +import { EntryFile, getEntryFile } from './entry_file' +import type { ModuleFormat } from './module_format' +import { normalizeFilePath } from './normalize_path' + // Taken from https://www.npmjs.com/package/cpy. const COPY_FILE_CONCURRENCY = os.cpus().length === 0 ? 2 : os.cpus().length * 2 @@ -21,11 +23,6 @@ const DEFAULT_USER_SUBDIRECTORY = 'src' type ArchiveFormat = 'none' | 'zip' -interface EntryFile { - contents: string - filename: string -} - interface ZipNodeParameters { aliases?: Map basePath: string @@ -33,6 +30,7 @@ interface ZipNodeParameters { extension: string filename: string mainFile: string + moduleFormat: ModuleFormat rewrites?: Map srcFiles: string[] } @@ -44,6 +42,7 @@ const createDirectory = async function ({ extension, filename, mainFile, + moduleFormat, rewrites = new Map(), srcFiles, }: ZipNodeParameters) { @@ -51,6 +50,7 @@ const createDirectory = async function ({ commonPrefix: basePath, filename, mainFile, + moduleFormat, userNamespace: DEFAULT_USER_SUBDIRECTORY, }) const functionFolder = join(destFolder, basename(filename, extension)) @@ -93,6 +93,7 @@ const createZipArchive = async function ({ extension, filename, mainFile, + moduleFormat, rewrites, srcFiles, }: ZipNodeParameters) { @@ -115,7 +116,7 @@ const createZipArchive = async function ({ const userNamespace = hasEntryFileConflict ? DEFAULT_USER_SUBDIRECTORY : '' if (needsEntryFile) { - const entryFile = getEntryFile({ commonPrefix: basePath, filename, mainFile, userNamespace }) + const entryFile = getEntryFile({ commonPrefix: basePath, filename, mainFile, moduleFormat, userNamespace }) addEntryFileToZip(archive, entryFile) } @@ -164,27 +165,6 @@ const addStat = async function (srcFile: string) { return { srcFile, stat } } -const getEntryFile = ({ - commonPrefix, - filename, - mainFile, - userNamespace, -}: { - commonPrefix: string - filename: string - mainFile: string - userNamespace: string -}): EntryFile => { - const mainPath = normalizeFilePath({ commonPrefix, path: mainFile, userNamespace }) - const extension = extname(filename) - const entryFilename = `${basename(filename, extension)}.js` - - return { - contents: `module.exports = require('.${mainPath.startsWith('/') ? mainPath : `/${mainPath}`}')`, - filename: entryFilename, - } -} - const zipJsFile = function ({ aliases = new Map(), archive, @@ -212,24 +192,4 @@ const zipJsFile = function ({ } } -// `adm-zip` and `require()` expect Unix paths. -// We remove the common path prefix. -// With files on different Windows drives, we remove the drive letter. -const normalizeFilePath = function ({ - commonPrefix, - path, - userNamespace, -}: { - commonPrefix: string - path: string - userNamespace: string -}) { - const userNamespacePathSegment = userNamespace ? `${userNamespace}${sep}` : '' - const pathA = normalize(path) - const pathB = pathA.replace(commonPrefix, userNamespacePathSegment) - const pathC = unixify(pathB) - return pathC -} - export { ArchiveFormat, zipNodeJs } -/* eslint-enable max-lines */ diff --git a/src/runtimes/runtime.ts b/src/runtimes/runtime.ts index cf70f4612..43eb0493c 100644 --- a/src/runtimes/runtime.ts +++ b/src/runtimes/runtime.ts @@ -4,7 +4,7 @@ import { FeatureFlags } from '../feature_flags' import { FunctionSource, SourceFile } from '../function' import { FsCache } from '../utils/fs' -import type { NodeBundlerName } from './node' +import type { NodeBundlerName } from './node/bundlers' import type { ISCValues } from './node/in_source_config' type RuntimeName = 'go' | 'js' | 'rs' diff --git a/tests/fixtures/node-esm/func1.js b/tests/fixtures/node-esm/func1.js new file mode 100644 index 000000000..767df32b9 --- /dev/null +++ b/tests/fixtures/node-esm/func1.js @@ -0,0 +1 @@ +export const handler = () => true diff --git a/tests/fixtures/node-esm/func2/func2.js b/tests/fixtures/node-esm/func2/func2.js new file mode 100644 index 000000000..767df32b9 --- /dev/null +++ b/tests/fixtures/node-esm/func2/func2.js @@ -0,0 +1 @@ +export const handler = () => true diff --git a/tests/fixtures/node-esm/package.json b/tests/fixtures/node-esm/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/fixtures/node-esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/main.js b/tests/main.js index b2cdf179a..dedd66d3d 100644 --- a/tests/main.js +++ b/tests/main.js @@ -2,6 +2,7 @@ const { mkdir, readFile, chmod, symlink, unlink, rename, stat, writeFile } = req const { tmpdir } = require('os') const { basename, dirname, isAbsolute, join, normalize, resolve, sep } = require('path') const { arch, env, platform, version: nodeVersion } = require('process') +const { pathToFileURL } = require('url') const test = require('ava') const cpy = require('cpy') @@ -29,6 +30,7 @@ const shellUtilsStub = sinon.stub(shellUtils, 'runCommand') const { zipFunction, listFunctions, listFunctionsFiles, listFunction } = require('..') const { ESBUILD_LOG_LIMIT } = require('../dist/runtimes/node/bundlers/esbuild/bundler') +const { detectEsModule } = require('../dist/runtimes/node/utils/detect_es_module') const { getRequires, zipNode, zipFixture, unzipFiles, zipCheckFunctions, FIXTURES_DIR } = require('./helpers/main') const { computeSha1 } = require('./helpers/sha') @@ -419,26 +421,44 @@ testMany( ) testMany( - 'Can bundle functions with `.js` extension using ES Modules', - ['bundler_esbuild', 'bundler_nft'], + 'Can bundle ESM functions and transpile them to CJS when the Node version is <14', + ['bundler_nft'], async (options, t) => { const length = 4 const fixtureName = 'local-require-esm' const opts = merge(options, { basePath: `${FIXTURES_DIR}/${fixtureName}`, + config: { + '*': { + nodeVersion: 'nodejs12.x', + }, + }, featureFlags: { defaultEsModulesToEsbuild: false }, }) - const { files, tmpDir } = await zipFixture(t, 'local-require-esm', { + const { files, tmpDir } = await zipFixture(t, fixtureName, { length, opts, }) await unzipFiles(files, (path) => `${path}/../${basename(path)}_out`) - const func1 = () => require(join(tmpDir, 'function.zip_out', 'function.js')) - const func2 = () => require(join(tmpDir, 'function_cjs.zip_out', 'function_cjs.js')) - const func3 = () => require(join(tmpDir, 'function_export_only.zip_out', 'function_export_only.js')) - const func4 = () => require(join(tmpDir, 'function_import_only.zip_out', 'function_import_only.js')) + const functionPaths = [ + join(tmpDir, 'function.zip_out', 'function.js'), + join(tmpDir, 'function_cjs.zip_out', 'function_cjs.js'), + join(tmpDir, 'function_export_only.zip_out', 'function_export_only.js'), + join(tmpDir, 'function_import_only.zip_out', 'function_import_only.js'), + ] + const func1 = () => require(functionPaths[0]) + const func2 = () => require(functionPaths[1]) + const func3 = () => require(functionPaths[2]) + const func4 = () => require(functionPaths[3]) + + const functionsAreESM = await Promise.all( + functionPaths.map((functionPath) => detectEsModule({ mainFile: functionPath })), + ) + + // None of the functions should be ESM since we're transpiling them to CJS. + t.false(functionsAreESM.some(Boolean)) // Dynamic imports are not supported in Node <13.2.0. if (semver.gte(nodeVersion, '13.2.0')) { @@ -452,7 +472,7 @@ testMany( ) testMany( - 'Can bundle functions with `.js` extension using ES Modules when `archiveType` is `none`', + 'Can bundle ESM functions and transpile them to CJS when the Node version is <14 and `archiveType` is `none`', ['bundler_esbuild', 'bundler_nft'], async (options, t) => { const length = 4 @@ -460,17 +480,35 @@ testMany( const opts = merge(options, { archiveFormat: 'none', basePath: `${FIXTURES_DIR}/${fixtureName}`, + config: { + '*': { + nodeVersion: 'nodejs12.x', + }, + }, featureFlags: { defaultEsModulesToEsbuild: false }, }) - const { tmpDir } = await zipFixture(t, 'local-require-esm', { + const { tmpDir } = await zipFixture(t, fixtureName, { length, opts, }) - const func1 = () => require(join(tmpDir, 'function', 'function.js')) - const func2 = () => require(join(tmpDir, 'function_cjs', 'function_cjs.js')) - const func3 = () => require(join(tmpDir, 'function_export_only', 'function_export_only.js')) - const func4 = () => require(join(tmpDir, 'function_import_only', 'function_import_only.js')) + const functionPaths = [ + join(tmpDir, 'function', 'function.js'), + join(tmpDir, 'function_cjs', 'function_cjs.js'), + join(tmpDir, 'function_export_only', 'function_export_only.js'), + join(tmpDir, 'function_import_only', 'function_import_only.js'), + ] + const func1 = () => require(functionPaths[0]) + const func2 = () => require(functionPaths[1]) + const func3 = () => require(functionPaths[2]) + const func4 = () => require(functionPaths[3]) + + const functionsAreESM = await Promise.all( + functionPaths.map((functionPath) => detectEsModule({ mainFile: functionPath })), + ) + + // None of the functions should be ESM since we're transpiling them to CJS. + t.false(functionsAreESM.some(Boolean)) // Dynamic imports are not supported in Node <13.2.0. if (semver.gte(nodeVersion, '13.2.0')) { @@ -506,6 +544,69 @@ testMany( }, ) +testMany( + 'Can bundle native ESM functions when the Node version is >=14 and the `zisi_pure_esm` flag is on', + ['bundler_nft'], + async (options, t) => { + const length = 2 + const fixtureName = 'node-esm' + const opts = merge(options, { + basePath: `${FIXTURES_DIR}/${fixtureName}`, + featureFlags: { zisi_pure_esm: true }, + }) + const { files, tmpDir } = await zipFixture(t, fixtureName, { + length, + opts, + }) + + await unzipFiles(files, (path) => `${path}/../${basename(path)}_out`) + + const functionPaths = [join(tmpDir, 'func1.zip_out', 'func1.js'), join(tmpDir, 'func2.zip_out', 'func2.js')] + const func1 = await import(pathToFileURL(functionPaths[0])) + const func2 = await import(pathToFileURL(functionPaths[1])) + + t.true(func1.handler()) + t.true(func2.handler()) + + const functionsAreESM = await Promise.all( + functionPaths.map((functionPath) => detectEsModule({ mainFile: functionPath })), + ) + + t.true(functionsAreESM.every(Boolean)) + }, +) + +testMany( + 'Can bundle ESM functions and transpile them to CJS when the Node version is >=14 and the `zisi_pure_esm` flag is off', + ['bundler_nft'], + async (options, t) => { + const length = 2 + const fixtureName = 'node-esm' + const opts = merge(options, { + basePath: `${FIXTURES_DIR}/${fixtureName}`, + }) + const { files, tmpDir } = await zipFixture(t, fixtureName, { + length, + opts, + }) + + await unzipFiles(files, (path) => `${path}/../${basename(path)}_out`) + + const functionPaths = [join(tmpDir, 'func1.zip_out', 'func1.js'), join(tmpDir, 'func2.zip_out', 'func2.js')] + const func1 = await import(pathToFileURL(functionPaths[0])) + const func2 = await import(pathToFileURL(functionPaths[1])) + + t.true(func1.handler()) + t.true(func2.handler()) + + const functionsAreESM = await Promise.all( + functionPaths.map((functionPath) => detectEsModule({ mainFile: functionPath })), + ) + + t.false(functionsAreESM.some(Boolean)) + }, +) + testMany( 'Can require local files deeply', ['bundler_default', 'bundler_esbuild', 'bundler_esbuild_zisi', 'bundler_default_nft', 'bundler_nft'],