Skip to content
This repository has been archived by the owner on May 22, 2024. It is now read-only.

Commit

Permalink
feat: always transform V2 functions (#1597)
Browse files Browse the repository at this point in the history
* feat: always bundle V2 functions

* refactor: rename to `transformer`

* refactor: always bundle

* refactor: update conditions

* chore: update tests
  • Loading branch information
eduardoboucas authored Oct 10, 2023
1 parent 1eccedc commit c9e5384
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 261 deletions.
135 changes: 26 additions & 109 deletions src/runtimes/node/bundlers/nft/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { basename, dirname, join, normalize, resolve, extname } from 'path'
import { basename, dirname, extname, join, normalize, resolve } from 'path'

import { nodeFileTrace } from '@vercel/nft'
import resolveDependency from '@vercel/nft/out/resolve-dependency.js'
Expand All @@ -10,13 +10,12 @@ import { cachedReadFile, getPathWithExtension } from '../../../../utils/fs.js'
import { minimatch } from '../../../../utils/matching.js'
import { getBasePath } from '../../utils/base_path.js'
import { filterExcludedPaths, getPathsOfIncludedFiles } from '../../utils/included_files.js'
import { MODULE_FORMAT, MODULE_FILE_EXTENSION, tsExtensions, ModuleFormat } from '../../utils/module_format.js'
import { MODULE_FILE_EXTENSION, tsExtensions } from '../../utils/module_format.js'
import { getNodeSupportMatrix } from '../../utils/node_version.js'
import { getClosestPackageJson } from '../../utils/package_json.js'
import type { GetSrcFilesFunction, BundleFunction } from '../types.js'

import { processESM } from './es_modules.js'
import { transpileTS } from './transpile.js'
import { transform, getTransformer } from './transformer.js'

const appearsToBeModuleName = (name: string) => !name.startsWith('.')

Expand Down Expand Up @@ -90,90 +89,6 @@ const getIgnoreFunction = (config: FunctionConfig) => {
}
}

/**
* Returns the module format that should be used when transpiling a TypeScript
* file.
*/
const getTSModuleFormat = async (
mainFile: string,
runtimeAPIVersion: number,
repositoryRoot?: string,
): Promise<ModuleFormat> => {
// TODO: This check should go away. We should always respect the format from
// the extension. We'll do this at a later stage, after we roll out the V2
// API with no side-effects on V1 functions.
if (runtimeAPIVersion === 2) {
if (extname(mainFile) === MODULE_FILE_EXTENSION.MTS) {
return MODULE_FORMAT.ESM
}

if (extname(mainFile) === MODULE_FILE_EXTENSION.CTS) {
return MODULE_FORMAT.COMMONJS
}
}

// At this point, we need to infer the module type from the `type` field in
// the closest `package.json`.
try {
const packageJSON = await getClosestPackageJson(dirname(mainFile), repositoryRoot)

if (packageJSON?.contents.type === 'module') {
return MODULE_FORMAT.ESM
}
} catch {
// no-op
}

return MODULE_FORMAT.COMMONJS
}

type TypeScriptTransformer = {
aliases: Map<string, string>
bundle?: boolean
bundledPaths?: string[]
format: ModuleFormat
newMainFile?: string
rewrites: Map<string, string>
}

const getTypeScriptTransformer = async (
runtimeAPIVersion: number,
mainFile: string,
repositoryRoot?: string,
): Promise<TypeScriptTransformer | undefined> => {
const isTypeScript = tsExtensions.has(extname(mainFile))

if (!isTypeScript) {
return
}

const format = await getTSModuleFormat(mainFile, runtimeAPIVersion, repositoryRoot)
const aliases = new Map<string, string>()
const rewrites = new Map<string, string>()
const transformer = {
aliases,
format,
rewrites,
}

if (runtimeAPIVersion === 2) {
// For V2 functions, we want to emit a main file with an unambiguous
// extension (i.e. `.cjs` or `.mjs`), so that the file is loaded with
// the correct format regardless of what is set in `package.json`.
const newExtension = format === MODULE_FORMAT.COMMONJS ? MODULE_FILE_EXTENSION.CJS : MODULE_FILE_EXTENSION.MJS
const newMainFile = getPathWithExtension(mainFile, newExtension)

return {
...transformer,
bundle: true,
bundledPaths: [],
newMainFile,
}
}

return transformer
}

const traceFilesAndTranspile = async function ({
basePath,
cache,
Expand All @@ -195,7 +110,9 @@ const traceFilesAndTranspile = async function ({
repositoryRoot?: string
runtimeAPIVersion: number
}) {
const tsTransformer = await getTypeScriptTransformer(runtimeAPIVersion, mainFile, repositoryRoot)
const isTSFunction = tsExtensions.has(extname(mainFile))
const transformer =
runtimeAPIVersion === 2 || isTSFunction ? await getTransformer(runtimeAPIVersion, mainFile, repositoryRoot) : null
const {
fileList: dependencyPaths,
esmFileList,
Expand All @@ -208,36 +125,36 @@ const traceFilesAndTranspile = async function ({
ignore: getIgnoreFunction(config),
readFile: async (path: string) => {
try {
const extension = extname(path)

if (tsExtensions.has(extension)) {
const { bundledPaths, transpiled } = await transpileTS({
bundle: tsTransformer?.bundle,
const isMainFile = path === mainFile

// Transform this file if this is the main file and we're processing a
// V2 functions (which always bundle local imports), or if this path is
// a TypeScript file (which should only happen for V1 TS functions that
// set the bundler to "nft").
if ((isMainFile && transformer) || tsExtensions.has(extname(path))) {
const { bundledPaths, transpiled } = await transform({
bundle: transformer?.bundle,
config,
name,
format: tsTransformer?.format,
format: transformer?.format,
path,
})
const isMainFile = path === mainFile

// If this is the main file, the final path of the compiled file may
// have been set by the transformer. It's fine to do this, since the
// only place where this file will be imported from is our entry file
// and we'll know the right path to use.
const newPath =
isMainFile && tsTransformer?.newMainFile
? tsTransformer.newMainFile
: getPathWithExtension(path, MODULE_FILE_EXTENSION.JS)
const newPath = transformer?.newMainFile ?? getPathWithExtension(path, MODULE_FILE_EXTENSION.JS)

// Overriding the contents of the `.ts` file.
tsTransformer?.rewrites.set(path, transpiled)
transformer?.rewrites.set(path, transpiled)

// Rewriting the `.ts` path to `.js` in the bundle.
tsTransformer?.aliases.set(path, newPath)
transformer?.aliases.set(path, newPath)

// Registering the input files that were bundled into the transpiled
// file.
tsTransformer?.bundledPaths?.push(...bundledPaths)
transformer?.bundledPaths?.push(...bundledPaths)

return transpiled
}
Expand Down Expand Up @@ -270,13 +187,13 @@ const traceFilesAndTranspile = async function ({
})
const normalizedTracedPaths = [...dependencyPaths].map((path) => (basePath ? resolve(basePath, path) : resolve(path)))

if (tsTransformer) {
if (transformer) {
return {
aliases: tsTransformer.aliases,
bundledPaths: tsTransformer.bundledPaths,
mainFile: tsTransformer.newMainFile ?? getPathWithExtension(mainFile, MODULE_FILE_EXTENSION.JS),
moduleFormat: tsTransformer.format,
rewrites: tsTransformer.rewrites,
aliases: transformer.aliases,
bundledPaths: transformer.bundledPaths,
mainFile: transformer.newMainFile ?? getPathWithExtension(mainFile, MODULE_FILE_EXTENSION.JS),
moduleFormat: transformer.format,
rewrites: transformer.rewrites,
tracedPaths: normalizedTracedPaths,
}
}
Expand Down
141 changes: 141 additions & 0 deletions src/runtimes/node/bundlers/nft/transformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { dirname, extname, resolve } from 'path'

import { build, BuildOptions } from 'esbuild'

import type { FunctionConfig } from '../../../../config.js'
import { FunctionBundlingUserError } from '../../../../utils/error.js'
import { getPathWithExtension } from '../../../../utils/fs.js'
import { RUNTIME } from '../../../runtime.js'
import { CJS_SHIM } from '../../utils/esm_cjs_compat.js'
import { MODULE_FORMAT, MODULE_FILE_EXTENSION, ModuleFormat } from '../../utils/module_format.js'
import { getClosestPackageJson } from '../../utils/package_json.js'
import { getBundlerTarget } from '../esbuild/bundler_target.js'
import { NODE_BUNDLER } from '../types.js'

type Transformer = {
aliases: Map<string, string>
bundle?: boolean
bundledPaths?: string[]
format: ModuleFormat
newMainFile?: string
rewrites: Map<string, string>
}

/**
* Returns the module format that should be used for a given function file.
*/
const getModuleFormat = async (
mainFile: string,
runtimeAPIVersion: number,
repositoryRoot?: string,
): Promise<ModuleFormat> => {
const extension = extname(mainFile)

// TODO: This check should go away. We should always respect the format from
// the extension. We'll do this at a later stage, after we roll out the V2
// API with no side-effects on V1 functions.
if (runtimeAPIVersion === 2) {
if (extension === MODULE_FILE_EXTENSION.MJS || extension === MODULE_FILE_EXTENSION.MTS) {
return MODULE_FORMAT.ESM
}

if (extension === MODULE_FILE_EXTENSION.CTS || extension === MODULE_FILE_EXTENSION.CTS) {
return MODULE_FORMAT.COMMONJS
}
}

// At this point, we need to infer the module type from the `type` field in
// the closest `package.json`.
try {
const packageJSON = await getClosestPackageJson(dirname(mainFile), repositoryRoot)

if (packageJSON?.contents.type === 'module') {
return MODULE_FORMAT.ESM
}
} catch {
// no-op
}

return MODULE_FORMAT.COMMONJS
}

export const getTransformer = async (
runtimeAPIVersion: number,
mainFile: string,
repositoryRoot?: string,
): Promise<Transformer | undefined> => {
const format = await getModuleFormat(mainFile, runtimeAPIVersion, repositoryRoot)
const aliases = new Map<string, string>()
const rewrites = new Map<string, string>()
const transformer = {
aliases,
format,
rewrites,
}

if (runtimeAPIVersion === 2) {
// For V2 functions, we want to emit a main file with an unambiguous
// extension (i.e. `.cjs` or `.mjs`), so that the file is loaded with
// the correct format regardless of what is set in `package.json`.
const newExtension = format === MODULE_FORMAT.COMMONJS ? MODULE_FILE_EXTENSION.CJS : MODULE_FILE_EXTENSION.MJS
const newMainFile = getPathWithExtension(mainFile, newExtension)

return {
...transformer,
bundle: true,
bundledPaths: [],
newMainFile,
}
}

return transformer
}

interface TransformOptions {
bundle?: boolean
config: FunctionConfig
format?: ModuleFormat
name: string
path: string
}

export const transform = async ({ bundle = false, config, format, name, path }: TransformOptions) => {
// 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)
const bundleOptions: BuildOptions = {
bundle: false,
}

if (bundle) {
bundleOptions.bundle = true
bundleOptions.packages = 'external'

if (format === MODULE_FORMAT.ESM) {
bundleOptions.banner = { js: CJS_SHIM }
}
}

try {
const transpiled = await build({
...bundleOptions,
entryPoints: [path],
format,
logLevel: 'error',
metafile: true,
platform: 'node',
sourcemap: Boolean(config.nodeSourcemap),
target: [nodeTarget],
write: false,
})
const bundledPaths = bundle ? Object.keys(transpiled.metafile.inputs).map((inputPath) => resolve(inputPath)) : []

return { bundledPaths, transpiled: transpiled.outputFiles[0].text }
} catch (error) {
throw FunctionBundlingUserError.addCustomErrorInfo(error, {
functionName: name,
runtime: RUNTIME.JAVASCRIPT,
bundler: NODE_BUNDLER.NFT,
})
}
}
Loading

1 comment on commit c9e5384

@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: 2.6s
  • largeDepsNft: 8.3s
  • largeDepsZisi: 15.7s

Please sign in to comment.