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

feat: detect V2 API using static analysis #1429

Merged
merged 14 commits into from
May 15, 2023
272 changes: 101 additions & 171 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
},
"dependencies": {
"@babel/parser": "7.16.8",
"@babel/plugin-syntax-typescript": "^7.21.4",
Copy link
Contributor

Choose a reason for hiding this comment

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

There is no need to install this. This plugin is for babel itself, but we are only using the parser, which has no concept of plugin packages. For the parser plugins are simply strings that enable features.

"@netlify/binary-info": "^1.0.0",
"@netlify/esbuild": "0.14.39",
"@netlify/serverless-functions-api": "^1.5.0",
Expand Down Expand Up @@ -88,6 +89,7 @@
"devDependencies": {
"@babel/types": "^7.15.6",
"@netlify/eslint-config-node": "^7.0.1",
"@skn0tt/lambda-local": "^2.0.3",
"@types/archiver": "^5.1.1",
"@types/glob": "^8.1.0",
"@types/is-ci": "^3.0.0",
Expand All @@ -104,7 +106,6 @@
"get-stream": "^6.0.0",
"husky": "^8.0.0",
"is-ci": "^3.0.1",
"lambda-local": "^2.0.3",
"npm-run-all": "^4.1.5",
"source-map-support": "^0.5.21",
"typescript": "^5.0.0",
Expand Down
20 changes: 14 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ interface AugmentedFunctionSource extends FunctionSource {
inSourceConfig?: ISCValues
}

const augmentWithISC = async (func: FunctionSource): Promise<AugmentedFunctionSource> => {
const augmentWithISC = async (func: FunctionSource, featureFlags: FeatureFlags): Promise<AugmentedFunctionSource> => {
// ISC is currently only supported in JavaScript and TypeScript functions
// and only supports scheduled functions.
if (func.runtime.name !== RUNTIME.JAVASCRIPT) {
return func
}

const inSourceConfig = await findISCDeclarationsInPath(func.mainFile, func.name)
const inSourceConfig = await findISCDeclarationsInPath(func.mainFile, func.name, featureFlags)

return { ...func, inSourceConfig }
}
Expand All @@ -71,7 +71,9 @@ export const listFunctions = async function (
const cache = new RuntimeCache()
const functionsMap = await getFunctionsFromPaths(paths, { cache, config, configFileDirectories, featureFlags })
const functions = [...functionsMap.values()]
const augmentedFunctions = parseISC ? await Promise.all(functions.map(augmentWithISC)) : functions
const augmentedFunctions = parseISC
? await Promise.all(functions.map((func) => augmentWithISC(func, featureFlags)))
: functions

return augmentedFunctions.map(getListedFunction)
}
Expand All @@ -94,7 +96,7 @@ export const listFunction = async function (
return
}

const augmentedFunction = parseISC ? await augmentWithISC(func) : func
const augmentedFunction = parseISC ? await augmentWithISC(func, featureFlags) : func

return getListedFunction(augmentedFunction)
}
Expand All @@ -116,7 +118,9 @@ export const listFunctionsFiles = async function (
const cache = new RuntimeCache()
const functionsMap = await getFunctionsFromPaths(paths, { cache, config, configFileDirectories, featureFlags })
const functions = [...functionsMap.values()]
const augmentedFunctions = parseISC ? await Promise.all(functions.map(augmentWithISC)) : functions
const augmentedFunctions = parseISC
? await Promise.all(functions.map((func) => augmentWithISC(func, featureFlags)))
: functions
const listedFunctionsFiles = await Promise.all(
augmentedFunctions.map((func) => getListedFunctionFiles(func, { basePath, featureFlags })),
)
Expand Down Expand Up @@ -147,7 +151,11 @@ const getListedFunctionFiles = async function (
func: AugmentedFunctionSource,
options: { basePath?: string; featureFlags: FeatureFlags },
): Promise<ListedFunctionFile[]> {
const srcFiles = await getSrcFiles({ ...func, ...options })
const srcFiles = await getSrcFiles({
...func,
...options,
runtimeAPIVersion: func.inSourceConfig?.runtimeAPIVersion ?? 1,
})

return srcFiles.map((srcFile) => ({ ...getListedFunction(func), srcFile, extension: extname(srcFile) }))
}
Expand Down
11 changes: 6 additions & 5 deletions src/runtimes/node/bundlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,19 @@ export const getBundlerName = async ({
extension,
featureFlags,
mainFile,
runtimeAPIVersion,
}: {
config: FunctionConfig
extension: string
featureFlags: FeatureFlags
mainFile: string
runtimeAPIVersion: number
}): Promise<NodeBundlerName> => {
if (nodeBundler) {
return nodeBundler
}

return await getDefaultBundler({ extension, featureFlags, mainFile })
return await getDefaultBundler({ extension, featureFlags, mainFile, runtimeAPIVersion })
}

const ESBUILD_EXTENSIONS = new Set(['.mjs', '.ts', '.tsx', '.cts', '.mts'])
Expand All @@ -57,15 +59,14 @@ const getDefaultBundler = async ({
extension,
featureFlags,
mainFile,
runtimeAPIVersion,
}: {
extension: string
mainFile: string
featureFlags: FeatureFlags
runtimeAPIVersion: number
}): Promise<NodeBundlerName> => {
if (
(extension === MODULE_FILE_EXTENSION.MJS && featureFlags.zisi_pure_esm_mjs) ||
featureFlags.zisi_functions_api_v2
) {
if ((extension === MODULE_FILE_EXTENSION.MJS && featureFlags.zisi_pure_esm_mjs) || runtimeAPIVersion === 2) {
return NODE_BUNDLER.NFT
}

Expand Down
17 changes: 6 additions & 11 deletions src/runtimes/node/bundlers/nft/es_modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const processESM = async ({
mainFile,
reasons,
name,
runtimeAPIVersion,
}: {
basePath: string | undefined
cache: RuntimeCache
Expand All @@ -70,15 +71,13 @@ export const processESM = async ({
mainFile: string
reasons: NodeFileTraceReasons
name: string
runtimeAPIVersion: number
}): Promise<{ rewrites?: Map<string, string>; moduleFormat: ModuleFormat }> => {
const extension = extname(mainFile)

// If this is a .mjs file and we want to output pure ESM files, we don't need
// to transpile anything.
if (
extension === MODULE_FILE_EXTENSION.MJS &&
(featureFlags.zisi_pure_esm_mjs || featureFlags.zisi_functions_api_v2)
) {
if (extension === MODULE_FILE_EXTENSION.MJS && (featureFlags.zisi_pure_esm_mjs || runtimeAPIVersion === 2)) {
return {
moduleFormat: MODULE_FORMAT.ESM,
}
Expand All @@ -87,7 +86,7 @@ export const processESM = async ({
const entrypointIsESM = isEntrypointESM({ basePath, esmPaths, mainFile })

if (!entrypointIsESM) {
if (featureFlags.zisi_functions_api_v2) {
if (runtimeAPIVersion === 2) {
throw new FunctionBundlingUserError(
`The function '${name}' must use the ES module syntax. To learn more, visit https://ntl.fyi/esm.`,
{
Expand All @@ -106,17 +105,13 @@ export const processESM = async ({
const packageJson = await getPackageJsonIfAvailable(dirname(mainFile))
const nodeSupport = getNodeSupportMatrix(config.nodeVersion)

if (
(featureFlags.zisi_pure_esm || featureFlags.zisi_functions_api_v2) &&
packageJson.type === 'module' &&
nodeSupport.esm
) {
if ((featureFlags.zisi_pure_esm || runtimeAPIVersion === 2) && packageJson.type === 'module' && nodeSupport.esm) {
return {
moduleFormat: MODULE_FORMAT.ESM,
}
}

if (featureFlags.zisi_functions_api_v2) {
if (runtimeAPIVersion === 2) {
throw new FunctionBundlingUserError(
`The function '${name}' must use the ES module syntax. To learn more, visit https://ntl.fyi/esm.`,
{
Expand Down
5 changes: 5 additions & 0 deletions src/runtimes/node/bundlers/nft/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const bundle: BundleFunction = async ({
name,
pluginsModulesPath,
repositoryRoot = basePath,
runtimeAPIVersion,
}) => {
const { includedFiles = [], includedFilesBasePath } = config
const { excludePatterns, paths: includedFilePaths } = await getPathsOfIncludedFiles(
Expand All @@ -46,6 +47,7 @@ const bundle: BundleFunction = async ({
mainFile,
pluginsModulesPath,
name,
runtimeAPIVersion,
})
const includedPaths = filterExcludedPaths(includedFilePaths, excludePatterns)
const filteredIncludedPaths = [...filterExcludedPaths(dependencyPaths, excludePatterns), ...includedPaths]
Expand Down Expand Up @@ -79,6 +81,7 @@ const traceFilesAndTranspile = async function ({
mainFile,
pluginsModulesPath,
name,
runtimeAPIVersion,
}: {
basePath?: string
cache: RuntimeCache
Expand All @@ -87,6 +90,7 @@ const traceFilesAndTranspile = async function ({
mainFile: string
pluginsModulesPath?: string
name: string
runtimeAPIVersion: number
}) {
const {
fileList: dependencyPaths,
Expand Down Expand Up @@ -140,6 +144,7 @@ const traceFilesAndTranspile = async function ({
mainFile,
reasons,
name,
runtimeAPIVersion,
})

return {
Expand Down
1 change: 1 addition & 0 deletions src/runtimes/node/bundlers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type BundleFunction = (
featureFlags: FeatureFlags
pluginsModulesPath?: string
repositoryRoot?: string
runtimeAPIVersion: number
} & FunctionSource,
) => Promise<{
// Aliases are used to change the path that a file should take inside the
Expand Down
44 changes: 37 additions & 7 deletions src/runtimes/node/in_source_config/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import type { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types'

import { FeatureFlags } from '../../../feature_flags.js'
import { InvocationMode, INVOCATION_MODE } from '../../../function.js'
import { FunctionBundlingUserError } from '../../../utils/error.js'
import { nonNullable } from '../../../utils/non_nullable.js'
import { RUNTIME } from '../../runtime.js'
import { createBindingsMethod } from '../parser/bindings.js'
import { getMainExport } from '../parser/exports.js'
import { getExports } from '../parser/exports.js'
import { getImports } from '../parser/imports.js'
import { safelyParseFile } from '../parser/index.js'
import { safelyParseSource, safelyReadSource } from '../parser/index.js'

import { parse as parseSchedule } from './properties/schedule.js'

export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions'

export type ISCValues = {
invocationMode?: InvocationMode
runtimeAPIVersion?: number
schedule?: string
}

Expand All @@ -37,22 +39,50 @@ const validateScheduleFunction = (functionFound: boolean, scheduleFound: boolean
// Parses a JS/TS file and looks for in-source config declarations. It returns
// an array of all declarations found, with `property` indicating the name of
// the property and `data` its value.
export const findISCDeclarationsInPath = async (sourcePath: string, functionName: string): Promise<ISCValues> => {
const ast = await safelyParseFile(sourcePath)
export const findISCDeclarationsInPath = async (
sourcePath: string,
functionName: string,
featureFlags: FeatureFlags,
): Promise<ISCValues> => {
const source = await safelyReadSource(sourcePath)

if (source === null) {
return {}
}

return findISCDeclarations(source, functionName, featureFlags)
}

export const findISCDeclarations = (source: string, functionName: string, featureFlags: FeatureFlags): ISCValues => {
const ast = safelyParseSource(source)

if (ast === null) {
return {}
}

const imports = ast.body.flatMap((node) => getImports(node, IN_SOURCE_CONFIG_MODULE))

const scheduledFunctionExpected = imports.some(({ imported }) => imported === 'schedule')

let scheduledFunctionFound = false
let scheduleFound = false

const getAllBindings = createBindingsMethod(ast.body)
const mainExports = getMainExport(ast.body, getAllBindings)
const iscExports = mainExports
const { configExport, defaultExport, handlerExports } = getExports(ast.body, getAllBindings)
const isV2API = handlerExports.length === 0 && defaultExport !== undefined

if (featureFlags.zisi_functions_api_v2 && isV2API) {
const config: ISCValues = {
runtimeAPIVersion: 2,
}

if (typeof configExport.schedule === 'string') {
config.schedule = configExport.schedule
}

return config
}

const iscExports = handlerExports
.map(({ args, local: exportName }) => {
const matchingImport = imports.find(({ local: importName }) => importName === exportName)

Expand Down
29 changes: 16 additions & 13 deletions src/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import { zipNodeJs } from './utils/zip.js'

// A proxy for the `getSrcFiles` that calls `getSrcFiles` on the bundler
const getSrcFilesWithBundler: GetSrcFilesFunction = async (parameters) => {
const { config, extension, featureFlags, mainFile, srcDir } = parameters
const { config, extension, featureFlags, mainFile, runtimeAPIVersion, srcDir } = parameters
const pluginsModulesPath = await getPluginsModulesPath(srcDir)
const bundlerName = await getBundlerName({
config,
extension,
featureFlags,
mainFile,
runtimeAPIVersion,
})
const bundler = getBundler(bundlerName)
const result = await bundler.getSrcFiles({ ...parameters, pluginsModulesPath })
Expand All @@ -48,15 +49,6 @@ const zipFunction: ZipFunction = async function ({
stat,
isInternal,
}) {
const pluginsModulesPath = await getPluginsModulesPath(srcDir)
const bundlerName = await getBundlerName({
config,
extension,
featureFlags,
mainFile,
})
const bundler = getBundler(bundlerName)

// If the file is a zip, we assume the function is bundled and ready to go.
// We simply copy it to the destination path with no further processing.
if (extension === '.zip') {
Expand All @@ -67,6 +59,17 @@ const zipFunction: ZipFunction = async function ({
return { config, path: destPath }
}

const inSourceConfig = await findISCDeclarationsInPath(mainFile, name, featureFlags)
Copy link
Member Author

Choose a reason for hiding this comment

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

I'd like to call out this change, since we're now parsing ISC before bundling. This means we'll start parsing ESM, CJS and TS rather than the bundled/transpiled CJS. I don't anticipate any problems with this, but it's not something I can easily wrap with a feature flag, so something to keep an eye on.

Copy link
Contributor

@danez danez May 15, 2023

Choose a reason for hiding this comment

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

We need to update the babel-parser in the future though, as we otherwise do not support the latest ES standard. It is currently locked at an old version because of previous performance problems. Last time I checked beginning of this year or end of last, the performance problems were still there I think.

But I think the problems are because of the zisi bundler and not necessarily the isc parsing.

const runtimeAPIVersion = inSourceConfig.runtimeAPIVersion === 2 ? 2 : 1
const pluginsModulesPath = await getPluginsModulesPath(srcDir)
const bundlerName = await getBundlerName({
config,
extension,
featureFlags,
mainFile,
runtimeAPIVersion,
})
const bundler = getBundler(bundlerName)
const {
aliases = new Map(),
cleanupFunction,
Expand All @@ -92,13 +95,12 @@ const zipFunction: ZipFunction = async function ({
pluginsModulesPath,
repositoryRoot,
runtime,
runtimeAPIVersion,
srcDir,
srcPath,
stat,
})

const inSourceConfig = await findISCDeclarationsInPath(mainFile, name)

createPluginsModulesPathAliases(srcFiles, pluginsModulesPath, aliases, finalBasePath)

const zipPath = await zipNodeJs({
Expand All @@ -113,6 +115,7 @@ const zipFunction: ZipFunction = async function ({
mainFile: finalMainFile,
moduleFormat,
rewrites,
runtimeAPIVersion,
srcFiles,
})

Expand All @@ -123,7 +126,7 @@ const zipFunction: ZipFunction = async function ({
let { invocationMode } = inSourceConfig

// If we're using the V2 API, force the invocation to "stream".
if (featureFlags.zisi_functions_api_v2) {
if (runtimeAPIVersion === 2) {
invocationMode = INVOCATION_MODE.Stream
}

Expand Down
Loading