Skip to content

Commit

Permalink
feat: detect V2 API using static analysis (netlify/zip-it-and-ship-it…
Browse files Browse the repository at this point in the history
…#1429)

* feat: detect V2 API using static analysis

* chore: add test case

* feat: propagate runtime API version

* feat: support `config` object

* refactor: simplify code

* chore: improve comments

* refactor: safely parse source

* refactor: rename property

* chore: update lock file

* chore: update tests

* chore: update tests

* chore: re-enable V2 tests

* refactor: bail when default export is found
  • Loading branch information
eduardoboucas authored May 15, 2023
1 parent e66d218 commit e8cbd06
Show file tree
Hide file tree
Showing 17 changed files with 527 additions and 246 deletions.
272 changes: 101 additions & 171 deletions packages/zip-it-and-ship-it/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/zip-it-and-ship-it/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",
"@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 packages/zip-it-and-ship-it/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 packages/zip-it-and-ship-it/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
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
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
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
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 packages/zip-it-and-ship-it/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)
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

0 comments on commit e8cbd06

Please sign in to comment.