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

feat: support CommonJS syntax in V2 functions #1585

Merged
merged 17 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 37 additions & 26 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Config } from './config.js'
import { FeatureFlags, getFlags } from './feature_flags.js'
import { FunctionSource } from './function.js'
import { getFunctionFromPath, getFunctionsFromPaths } from './runtimes/index.js'
import { findISCDeclarationsInPath, ISCValues } from './runtimes/node/in_source_config/index.js'
import { parseFile, StaticAnalysisResult } from './runtimes/node/in_source_config/index.js'
import { ModuleFormat } from './runtimes/node/utils/module_format.js'
import { GetSrcFilesFunction, RuntimeName, RUNTIME } from './runtimes/runtime.js'
import { RuntimeCache } from './utils/cache.js'
import { listFunctionsDirectories, resolveFunctionsDirectories } from './utils/fs.js'
Expand All @@ -27,6 +28,7 @@ export interface ListedFunction {
schedule?: string
displayName?: string
generator?: string
inputModuleFormat?: ModuleFormat
}

type ListedFunctionFile = ListedFunction & {
Expand All @@ -42,54 +44,60 @@ interface ListFunctionsOptions {
}

interface AugmentedFunctionSource extends FunctionSource {
inSourceConfig?: ISCValues
staticAnalysisResult?: StaticAnalysisResult
}

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

const inSourceConfig = await findISCDeclarationsInPath(func.mainFile, {
const staticAnalysisResult = await parseFile(func.mainFile, {
functionName: func.name,
logger: getLogger(),
})

return { ...func, inSourceConfig }
return { ...func, staticAnalysisResult }
}

interface ListFunctionsOptions {
basePath?: string
config?: Config
configFileDirectories?: string[]
featureFlags?: FeatureFlags
parseISC?: boolean
}

// List all Netlify Functions main entry files for a specific directory
export const listFunctions = async function (
relativeSrcFolders: string | string[],
{
featureFlags: inputFeatureFlags,
config,
configFileDirectories,
parseISC = false,
}: { featureFlags?: FeatureFlags; config?: Config; configFileDirectories?: string[]; parseISC?: boolean } = {},
{ featureFlags: inputFeatureFlags, config, configFileDirectories, parseISC = false }: ListFunctionsOptions = {},
) {
const featureFlags = getFlags(inputFeatureFlags)
const srcFolders = resolveFunctionsDirectories(relativeSrcFolders)
const paths = await listFunctionsDirectories(srcFolders)
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((func) => augmentWithISC(func))) : functions
const augmentedFunctions = parseISC
? await Promise.all(functions.map((func) => augmentWithStaticAnalysis(func)))
: functions

return augmentedFunctions.map(getListedFunction)
}

interface ListFunctionOptions {
basePath?: string
config?: Config
configFileDirectories?: string[]
featureFlags?: FeatureFlags
parseISC?: boolean
}

// Finds a function at a specific path.
export const listFunction = async function (
path: string,
{
featureFlags: inputFeatureFlags,
config,
configFileDirectories,
parseISC = false,
}: { featureFlags?: FeatureFlags; config?: Config; configFileDirectories?: string[]; parseISC?: boolean } = {},
{ featureFlags: inputFeatureFlags, config, configFileDirectories, parseISC = false }: ListFunctionOptions = {},
) {
const featureFlags = getFlags(inputFeatureFlags)
const cache = new RuntimeCache()
Expand All @@ -99,7 +107,7 @@ export const listFunction = async function (
return
}

const augmentedFunction = parseISC ? await augmentWithISC(func) : func
const augmentedFunction = parseISC ? await augmentWithStaticAnalysis(func) : func

return getListedFunction(augmentedFunction)
}
Expand All @@ -121,7 +129,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((func) => augmentWithISC(func))) : functions
const augmentedFunctions = parseISC
? await Promise.all(functions.map((func) => augmentWithStaticAnalysis(func)))
: functions
const listedFunctionsFiles = await Promise.all(
augmentedFunctions.map((func) => getListedFunctionFiles(func, { basePath, featureFlags })),
)
Expand All @@ -132,7 +142,7 @@ export const listFunctionsFiles = async function (
const getListedFunction = function ({
config,
extension,
inSourceConfig,
staticAnalysisResult,
mainFile,
name,
runtime,
Expand All @@ -144,8 +154,9 @@ const getListedFunction = function ({
mainFile,
name,
runtime: runtime.name,
runtimeAPIVersion: inSourceConfig ? inSourceConfig?.runtimeAPIVersion ?? 1 : undefined,
schedule: inSourceConfig?.schedule ?? config.schedule,
runtimeAPIVersion: staticAnalysisResult ? staticAnalysisResult?.runtimeAPIVersion ?? 1 : undefined,
schedule: staticAnalysisResult?.schedule ?? config.schedule,
inputModuleFormat: staticAnalysisResult?.inputModuleFormat,
}
}

Expand All @@ -156,7 +167,7 @@ const getListedFunctionFiles = async function (
const srcFiles = await getSrcFiles({
...func,
...options,
runtimeAPIVersion: func.inSourceConfig?.runtimeAPIVersion ?? 1,
runtimeAPIVersion: func.staticAnalysisResult?.runtimeAPIVersion ?? 1,
})

return srcFiles.map((srcFile) => ({ ...getListedFunction(func), srcFile, extension: extname(srcFile) }))
Expand Down
76 changes: 46 additions & 30 deletions src/runtimes/node/in_source_config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,27 @@ import { getRoutes, Route } from '../../../utils/routes.js'
import { RUNTIME } from '../../runtime.js'
import { NODE_BUNDLER } from '../bundlers/types.js'
import { createBindingsMethod } from '../parser/bindings.js'
import { getExports } from '../parser/exports.js'
import { traverseNodes } from '../parser/exports.js'
import { getImports } from '../parser/imports.js'
import { safelyParseSource, safelyReadSource } from '../parser/index.js'
import type { ModuleFormat } from '../utils/module_format.js'

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

export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions'

export type ISCValues = {
invocationMode?: InvocationMode
routes?: Route[]
runtimeAPIVersion?: number
schedule?: string
methods?: string[]
}

export interface StaticAnalysisResult extends ISCValues {
Copy link
Member Author

Choose a reason for hiding this comment

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

We were using the term "ISC" to contain properties that are not part of the in-source configuration spec, like invocationMode or runtimeAPIVersion. They were grouped together because we extract both those properties and the ISC properties using the same static analysis step, but functionally they are different things.

So now we have a ISCValues type that holds just the in-source configuration properties, and a StaticAnalysisResult that holds those plus some additional metadata properties we'll extract using the static analysis mechanism.

A big part of this diff is just renaming ISC-related terms to a more generic "static analysis result".

inputModuleFormat?: ModuleFormat
invocationMode?: InvocationMode
runtimeAPIVersion?: number
}

interface FindISCDeclarationsOptions {
functionName: string
logger: Logger
Expand All @@ -45,22 +50,6 @@ 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, logger }: FindISCDeclarationsOptions,
): Promise<ISCValues> => {
const source = await safelyReadSource(sourcePath)

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

return findISCDeclarations(source, { functionName, logger })
}

/**
* Normalizes method names into arrays of uppercase strings.
* (e.g. "get" becomes ["GET"])
Expand All @@ -84,10 +73,32 @@ const normalizeMethods = (input: unknown, name: string): string[] | undefined =>
})
}

export const findISCDeclarations = (
/**
* Loads a file at a given path, parses it into an AST, and returns a series of
* data points, such as in-source configuration properties and other metadata.
*/
export const parseFile = async (
Copy link
Member Author

Choose a reason for hiding this comment

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

This was renamed from findISCDeclarationsInPath. No functionality has changed.

sourcePath: string,
{ functionName, logger }: FindISCDeclarationsOptions,
): Promise<StaticAnalysisResult> => {
const source = await safelyReadSource(sourcePath)

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

return parseSource(source, { functionName, logger })
}

/**
* Takes a JS/TS source as a string, parses it into an AST, and returns a
* series of data points, such as in-source configuration properties and
* other metadata.
*/
export const parseSource = (
source: string,
{ functionName, logger }: FindISCDeclarationsOptions,
): ISCValues => {
): StaticAnalysisResult => {
const ast = safelyParseSource(source)

if (ast === null) {
Expand All @@ -101,27 +112,28 @@ export const findISCDeclarations = (
let scheduleFound = false

const getAllBindings = createBindingsMethod(ast.body)
const { configExport, defaultExport, handlerExports } = getExports(ast.body, getAllBindings)
const isV2API = handlerExports.length === 0 && defaultExport !== undefined
const { configExport, handlerExports, hasDefaultExport, inputModuleFormat } = traverseNodes(ast.body, getAllBindings)
Copy link
Member Author

Choose a reason for hiding this comment

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

We were not using the contents of defaultExport for anything, just checking whether it exists or not. So I've changed it to a boolean that is now true if ESM or CJS default exports are found.

const isV2API = handlerExports.length === 0 && hasDefaultExport

if (isV2API) {
const config: ISCValues = {
const result: StaticAnalysisResult = {
inputModuleFormat,
runtimeAPIVersion: 2,
}

logger.system('detected v2 function')

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

if (configExport.method !== undefined) {
config.methods = normalizeMethods(configExport.method, functionName)
result.methods = normalizeMethods(configExport.method, functionName)
}

config.routes = getRoutes(configExport.path, functionName, config.methods ?? [])
result.routes = getRoutes(configExport.path, functionName, result.methods ?? [])

return config
return result
}

const iscExports = handlerExports
Expand Down Expand Up @@ -171,7 +183,7 @@ export const findISCDeclarations = (

const mergedExports: ISCValues = iscExports.reduce((acc, obj) => ({ ...acc, ...obj }), {})

return { ...mergedExports, runtimeAPIVersion: 1 }
return { ...mergedExports, inputModuleFormat, runtimeAPIVersion: 1 }
}

export type ISCHandlerArg = ArgumentPlaceholder | Expression | SpreadElement | JSXNamespacedName
Expand All @@ -181,5 +193,9 @@ export type ISCExportWithCallExpression = {
args: ISCHandlerArg[]
local: string
}
export type ISCExportWithObject = {
type: 'object-expression'
object: Record<string, unknown>
}
export type ISCExportOther = { type: 'other' }
export type ISCExport = ISCExportWithCallExpression | ISCExportOther
export type ISCExport = ISCExportWithCallExpression | ISCExportWithObject | ISCExportOther
10 changes: 5 additions & 5 deletions src/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { GetSrcFilesFunction, Runtime, RUNTIME, ZipFunction } from '../runtime.j
import { getBundler, getBundlerName } from './bundlers/index.js'
import { NODE_BUNDLER } from './bundlers/types.js'
import { findFunctionsInPaths, findFunctionInPath } from './finder.js'
import { findISCDeclarationsInPath } from './in_source_config/index.js'
import { parseFile } from './in_source_config/index.js'
import { MODULE_FORMAT, MODULE_FILE_EXTENSION } from './utils/module_format.js'
import { getNodeRuntime, getNodeRuntimeForV2 } from './utils/node_runtime.js'
import { createAliases as createPluginsModulesPathAliases, getPluginsModulesPath } from './utils/plugin_modules_path.js'
Expand Down Expand Up @@ -61,8 +61,8 @@ const zipFunction: ZipFunction = async function ({
return { config, path: destPath, entryFilename: '' }
}

const inSourceConfig = await findISCDeclarationsInPath(mainFile, { functionName: name, logger })
const runtimeAPIVersion = inSourceConfig.runtimeAPIVersion === 2 ? 2 : 1
const staticAnalysisResult = await parseFile(mainFile, { functionName: name, logger })
const runtimeAPIVersion = staticAnalysisResult.runtimeAPIVersion === 2 ? 2 : 1

const pluginsModulesPath = await getPluginsModulesPath(srcDir)
const bundlerName = await getBundlerName({
Expand Down Expand Up @@ -128,7 +128,7 @@ const zipFunction: ZipFunction = async function ({

// Getting the invocation mode from ISC, in case the function is using the
// `stream` helper.
let { invocationMode } = inSourceConfig
let { invocationMode } = staticAnalysisResult

// If we're using the V2 API, force the invocation to "stream".
if (runtimeAPIVersion === 2) {
Expand All @@ -152,7 +152,7 @@ const zipFunction: ZipFunction = async function ({
generator: config?.generator || getInternalValue(isInternal),
inputs,
includedFiles,
inSourceConfig,
staticAnalysisResult,
invocationMode,
outputModuleFormat,
nativeNodeModules,
Expand Down
Loading