diff --git a/src/runtimes/node/bundlers/nft/index.ts b/src/runtimes/node/bundlers/nft/index.ts index 490dd4b61..85db103c9 100644 --- a/src/runtimes/node/bundlers/nft/index.ts +++ b/src/runtimes/node/bundlers/nft/index.ts @@ -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 + [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, } } diff --git a/src/runtimes/node/bundlers/nft/transpile.ts b/src/runtimes/node/bundlers/nft/transpile.ts new file mode 100644 index 000000000..061f58764 --- /dev/null +++ b/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 = new Map() + + await Promise.all( + paths.map(async (path) => { + const transpiled = await transpile(path, config) + + transpiledPaths.set(transpiled.path, path) + }), + ) + + return transpiledPaths +} + +export { transpileMany } diff --git a/tests/fixtures/node-module-scope/function.js b/tests/fixtures/node-module-scope/function.js index 4006f94ff..4d9042e6b 100644 --- a/tests/fixtures/node-module-scope/function.js +++ b/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' diff --git a/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/index.js b/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/index.js new file mode 100644 index 000000000..dc03a1aeb --- /dev/null +++ b/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/index.js @@ -0,0 +1,3 @@ +const stack = require('./stack') + +module.exports = { stack } diff --git a/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/package.json b/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/package.json new file mode 100644 index 000000000..970c87ddd --- /dev/null +++ b/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/package.json @@ -0,0 +1,4 @@ +{ + "name": "@netlify/mock-package", + "version": "1.0.0" +} diff --git a/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/stack.js b/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/stack.js new file mode 100644 index 000000000..5c0656140 --- /dev/null +++ b/tests/fixtures/node-module-scope/node_modules/@netlify/mock-package/stack.js @@ -0,0 +1 @@ +module.exports = 'jam' diff --git a/tests/main.js b/tests/main.js index 3223f89cd..b3bd245cc 100644 --- a/tests/main.js +++ b/tests/main.js @@ -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 } }) @@ -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 @@ -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,