Skip to content

Commit

Permalink
feat: add ESM support when bundling with NFT (#759)
Browse files Browse the repository at this point in the history
* feat: add ESM support when bundling with NFT

* chore: disable test for NFT

* chore: update test fixture

* refactor: remove types file

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
eduardoboucas and kodiakhq[bot] committed Oct 22, 2021
1 parent 4167cfc commit b63ff7e
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 38 deletions.
118 changes: 85 additions & 33 deletions src/runtimes/node/bundlers/nft/index.ts
Expand Up @@ -3,50 +3,102 @@ import { dirname, normalize, resolve } from 'path'
import { nodeFileTrace } from '@vercel/nft'

import type { BundleFunction } from '..'
import type { FunctionConfig } from '../../../../config'
import { cachedReadFile, FsCache, safeUnlink } from '../../../../utils/fs'
import type { GetSrcFilesFunction } from '../../../runtime'
import { getBasePath } from '../../utils/base_path'
import { filterExcludedPaths, getPathsOfIncludedFiles } from '../../utils/included_files'

const bundle: BundleFunction = async ({
basePath,
config,
extension,
featureFlags,
filename,
mainFile,
name,
pluginsModulesPath,
repositoryRoot = basePath,
runtime,
srcDir,
srcPath,
stat,
}) => {
const srcFiles = await getSrcFiles({
import { transpileMany } from './transpile'

interface NftCache {
analysisCache?: Map<string, { isESM: boolean; [key: string]: unknown }>
[key: string]: unknown
}

const bundle: BundleFunction = async ({ basePath, config, mainFile, repositoryRoot = basePath }) => {
const { includedFiles = [], includedFilesBasePath } = config
const { exclude: excludedPaths, paths: includedFilePaths } = await getPathsOfIncludedFiles(
includedFiles,
includedFilesBasePath || basePath,
)
const {
cleanupFunction,
paths: dependencyPaths,
transpilation,
} = await traceFilesAndTranspile({
basePath: repositoryRoot,
config: {
...config,
includedFilesBasePath: config.includedFilesBasePath || basePath,
},
extension,
featureFlags,
filename,
config,
mainFile,
name,
pluginsModulesPath,
repositoryRoot,
runtime,
srcDir,
srcPath,
stat,
})
const dirnames = srcFiles.map((filePath) => normalize(dirname(filePath))).sort()
const filteredIncludedPaths = filterExcludedPaths([...dependencyPaths, ...includedFilePaths], excludedPaths)
const dirnames = filteredIncludedPaths.map((filePath) => normalize(dirname(filePath))).sort()

return {
aliases: transpilation,
basePath: getBasePath(dirnames),
inputs: srcFiles,
cleanupFunction,
inputs: dependencyPaths,
mainFile,
srcFiles,
srcFiles: [...filteredIncludedPaths, ...transpilation.keys()],
}
}

const traceFilesAndTranspile = async function ({
basePath,
config,
mainFile,
}: {
basePath?: string
config: FunctionConfig
mainFile: string
}) {
const fsCache: FsCache = {}
const cache: NftCache = {}
const { fileList: dependencyPaths } = await nodeFileTrace([mainFile], {
base: basePath,
cache,
readFile: async (path: string) => {
try {
const source = (await cachedReadFile(fsCache, path, 'utf8')) as string

return source
} catch (error) {
if (error.code === 'ENOENT' || error.code === 'EISDIR') {
return null
}

throw error
}
},
})
const normalizedDependencyPaths = dependencyPaths.map((path) => (basePath ? resolve(basePath, path) : resolve(path)))

// We look at the cache object to find any paths corresponding to ESM files.
const esmPaths = [...(cache.analysisCache?.entries() || [])].filter(([, { isESM }]) => isESM).map(([path]) => path)

// After transpiling the ESM files, we get back a `Map` mapping the path of
// each transpiled to its original path.
const transpilation = await transpileMany(esmPaths, config)

// Creating a `Set` with the original paths of the transpiled files so that
// we can do a O(1) lookup.
const originalPaths = new Set(transpilation.values())

// We remove the transpiled paths from the list of traced files, otherwise we
// would end up with duplicate files in the archive.
const filteredDependencyPaths = normalizedDependencyPaths.filter((path) => !originalPaths.has(path))

// The cleanup function will delete all the temporary files that were created
// as part of the transpilation process.
const cleanupFunction = async () => {
await Promise.all([...transpilation.keys()].map(safeUnlink))
}

return {
cleanupFunction,
paths: filteredDependencyPaths,
transpilation,
}
}

Expand Down
46 changes: 46 additions & 0 deletions src/runtimes/node/bundlers/nft/transpile.ts
@@ -0,0 +1,46 @@
import { build } from '@netlify/esbuild'
import { tmpName } from 'tmp-promise'

import type { FunctionConfig } from '../../../../config'
import { safeUnlink } from '../../../../utils/fs'
import { getBundlerTarget } from '../esbuild/bundler_target'

const transpile = async (path: string, config: FunctionConfig) => {
const targetPath = await tmpName({ postfix: '.js' })
const cleanupFn = () => safeUnlink(targetPath)

// The version of ECMAScript to use as the build target. This will determine
// whether certain features are transpiled down or left untransformed.
const nodeTarget = getBundlerTarget(config.nodeVersion)

await build({
bundle: false,
entryPoints: [path],
format: 'cjs',
logLevel: 'error',
outfile: targetPath,
platform: 'node',
target: [nodeTarget],
})

return {
cleanupFn,
path: targetPath,
}
}

const transpileMany = async (paths: string[], config: FunctionConfig) => {
const transpiledPaths: Map<string, string> = new Map()

await Promise.all(
paths.map(async (path) => {
const transpiled = await transpile(path, config)

transpiledPaths.set(transpiled.path, path)
}),
)

return transpiledPaths
}

export { transpileMany }
4 changes: 2 additions & 2 deletions tests/fixtures/node-module-scope/function.js
@@ -1,3 +1,3 @@
const scopeModule = require('@netlify/eslint-config-node')
const { stack } = require('@netlify/mock-package')

module.exports = typeof scopeModule === 'object'
module.exports = stack === 'jam'

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions tests/main.js
Expand Up @@ -398,7 +398,7 @@ testMany(

testMany(
'Can bundle functions with `.js` extension using ES Modules and feature flag ON',
['bundler_esbuild', 'bundler_default', 'todo:bundler_nft'],
['bundler_esbuild', 'bundler_default', 'bundler_nft'],
async (options, t) => {
const opts = merge(options, { featureFlags: { defaultEsModulesToEsbuild: true } })

Expand All @@ -411,7 +411,7 @@ testMany(

testMany(
'Can bundle functions with `.js` extension using ES Modules and feature flag OFF',
['bundler_esbuild', 'bundler_default', 'todo:bundler_nft'],
['bundler_esbuild', 'bundler_default', 'bundler_nft'],
async (options, t) => {
const bundler = options.config['*'].nodeBundler

Expand Down Expand Up @@ -1206,7 +1206,7 @@ testMany(

testMany(
'Handles a JavaScript function ({name}.mjs, {name}/{name}.mjs, {name}/index.mjs)',
['bundler_esbuild', 'bundler_default', 'todo:bundler_nft'],
['bundler_esbuild', 'bundler_default'],
async (options, t) => {
const { files, tmpDir } = await zipFixture(t, 'node-mjs', {
length: 3,
Expand Down

1 comment on commit b63ff7e

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏱ Benchmark results

largeDepsEsbuild: 6.4s

largeDepsNft: 46s

largeDepsZisi: 54.9s

Please sign in to comment.