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

fix: detect a wider range of scheduled functions #1105

Merged
merged 6 commits into from
Jun 9, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/runtimes/node/in_source_config/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types'

import { nonNullable } from '../../../utils/non_nullable.js'
import { createBindingsMethod } from '../parser/bindings.js'
import { getMainExport } from '../parser/exports.js'
import { getImports } from '../parser/imports.js'
import { safelyParseFile } from '../parser/index.js'
Expand All @@ -22,7 +23,8 @@ export const findISCDeclarationsInPath = async (sourcePath: string): Promise<ISC
}

const imports = ast.body.flatMap((node) => getImports(node, IN_SOURCE_CONFIG_MODULE))
const mainExports = getMainExport(ast.body)
const getAllBindings = createBindingsMethod(ast.body)
const mainExports = getMainExport(ast.body, getAllBindings)
const iscExports = mainExports
.map(({ args, local: exportName }) => {
const matchingImport = imports.find(({ local: importName }) => importName === exportName)
Expand All @@ -33,7 +35,7 @@ export const findISCDeclarationsInPath = async (sourcePath: string): Promise<ISC

switch (matchingImport.imported) {
case 'schedule':
return parseSchedule({ args })
return parseSchedule({ args }, getAllBindings)

default:
// no-op
Expand Down
13 changes: 11 additions & 2 deletions src/runtimes/node/in_source_config/properties/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { BindingMethod } from '../../parser/bindings.js'
import type { ISCHandlerArg } from '../index.js'

export const parse = ({ args }: { args: ISCHandlerArg[] }) => {
const [expression] = args
export const parse = ({ args }: { args: ISCHandlerArg[] }, getAllBindings: BindingMethod) => {
let [expression] = args

if (expression.type === 'Identifier') {
const binding = getAllBindings().get(expression.name)
if (binding) {
expression = binding
}
}

const schedule = expression.type === 'StringLiteral' ? expression.value : undefined

return {
Expand Down
56 changes: 56 additions & 0 deletions src/runtimes/node/parser/bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Expression, Statement, VariableDeclaration } from '@babel/types'

type Bindings = Map<string, Expression>

const getBindingFromVariableDeclaration = function (node: VariableDeclaration, bindings: Bindings): void {
node.declarations.forEach((declaration) => {
if (declaration.id.type === 'Identifier' && declaration.init) {
bindings.set(declaration.id.name, declaration.init)
}
})
}

// eslint-disable-next-line complexity
const getBindingsFromNode = function (node: Statement, bindings: Bindings): void {
if (node.type === 'VariableDeclaration') {
// A variable was created, so create it and store the potential value
getBindingFromVariableDeclaration(node, bindings)
} else if (
node.type === 'ExpressionStatement' &&
node.expression.type === 'AssignmentExpression' &&
node.expression.left.type === 'Identifier'
) {
// The variable was reassigned, so let's store the new value
bindings.set(node.expression.left.name, node.expression.right)
} else if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') {
// A `export const|let ...` creates a binding that can later be referenced again
getBindingFromVariableDeclaration(node.declaration, bindings)
}
}

/**
* Goes through all relevant nodes and creates a map from binding name to assigned value/expression
*/
const getAllBindings = function (nodes: Statement[]): Bindings {
const bindings: Bindings = new Map()

nodes.forEach((node) => {
getBindingsFromNode(node, bindings)
})

return bindings
}

export type BindingMethod = () => Bindings

export const createBindingsMethod = function (nodes: Statement[]): BindingMethod {
// memoize the result for these nodes
let result: Bindings
danez marked this conversation as resolved.
Show resolved Hide resolved
return () => {
if (!result) {
result = getAllBindings(nodes)
}
danez marked this conversation as resolved.
Show resolved Hide resolved

return result
}
}
58 changes: 46 additions & 12 deletions src/runtimes/node/parser/exports.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { CallExpression, Statement } from '@babel/types'
import type { ExportNamedDeclaration, ExportSpecifier, Expression, Statement } from '@babel/types'

import type { ISCExport } from '../in_source_config/index.js'

import type { BindingMethod } from './bindings.js'
import { isModuleExports } from './helpers.js'

// Finds the main handler export in an AST.
export const getMainExport = (nodes: Statement[]) => {
export const getMainExport = (nodes: Statement[], getAllBindings: BindingMethod) => {
let handlerExport: ISCExport[] = []

nodes.find((node) => {
const esmExports = getMainExportFromESM(node)
const esmExports = getMainExportFromESM(node, getAllBindings)

if (esmExports.length !== 0) {
handlerExport = esmExports
Expand Down Expand Up @@ -39,24 +40,27 @@ const getMainExportFromCJS = (node: Statement) => {
]

return handlerPaths.flatMap((handlerPath) => {
if (!isModuleExports(node, handlerPath) || node.expression.right.type !== 'CallExpression') {
if (!isModuleExports(node, handlerPath)) {
return []
}

return getExportsFromCallExpression(node.expression.right)
return getExportsFromExpression(node.expression.right)
})
}

// Finds the main handler export in an ESM AST.
// eslint-disable-next-line complexity
const getMainExportFromESM = (node: Statement) => {
const getMainExportFromESM = (node: Statement, getAllBindings: BindingMethod) => {
if (node.type !== 'ExportNamedDeclaration' || node.exportKind !== 'value') {
return []
}

const { declaration } = node
const { declaration, specifiers } = node

if (!declaration || declaration.type !== 'VariableDeclaration') {
if (specifiers?.length > 0) {
return getExportsFromBindings(specifiers, getAllBindings)
}

if (declaration?.type !== 'VariableDeclaration') {
return []
}

Expand All @@ -66,16 +70,46 @@ const getMainExportFromESM = (node: Statement) => {
return type === 'VariableDeclarator' && id.type === 'Identifier' && id.name === 'handler'
})

if (handlerDeclaration?.init?.type !== 'CallExpression') {
const exports = getExportsFromExpression(handlerDeclaration?.init)

return exports
}

// Check if the Node is an ExportSpecifier that has a named export called `handler`
// either with Identifier `export { handler }`
// or with StringLiteral `export { x as "handler" }`
const isHandlerExport = (node: ExportNamedDeclaration['specifiers'][number]): node is ExportSpecifier => {
const { type, exported } = node
return (
danez marked this conversation as resolved.
Show resolved Hide resolved
type === 'ExportSpecifier' &&
((exported.type === 'Identifier' && exported.name === 'handler') ||
(exported.type === 'StringLiteral' && exported.value === 'handler'))
)
}

// Tries to resolve the export from a binding (variable)
// for example `let handler; handler = () => {}; export { handler }` would
// resolve correctly to the handler function
const getExportsFromBindings = (specifiers: ExportNamedDeclaration['specifiers'], getAllBindings: BindingMethod) => {
const specifier = specifiers.find(isHandlerExport)

if (!specifier) {
return []
}

const exports = getExportsFromCallExpression(handlerDeclaration.init)
const binding = getAllBindings().get(specifier.local.name)
const exports = getExportsFromExpression(binding)

return exports
}

const getExportsFromCallExpression = (node: CallExpression) => {
const getExportsFromExpression = (node: Expression | undefined | null) => {
// We're only interested in expressions representing function calls, because
// the ISC patterns we implement at the moment are all helper functions.
if (node?.type !== 'CallExpression') {
danez marked this conversation as resolved.
Show resolved Hide resolved
return []
}
danez marked this conversation as resolved.
Show resolved Hide resolved

const { arguments: args, callee } = node

if (callee.type !== 'Identifier') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { schedule } from '@netlify/functions'

const handler = schedule('@daily', async () => {
// function handler
})

export { handler }
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { schedule } from '@netlify/functions'

const _handler = schedule('@daily', async () => {
// function handler
})
export { _handler as handler }
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { schedule } from '@netlify/functions'

const handler = async () => {
// function handler
}

const _handler = schedule('@daily', handler)
export { _handler as handler }
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { schedule } from '@netlify/functions'

const SCHEDULE = '@daily'

export const handler = schedule(SCHEDULE, async () => {
// function handler
})
4 changes: 2 additions & 2 deletions tests/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2662,7 +2662,7 @@ testMany(
'Finds in-source config declarations using the `schedule` helper',
['bundler_default', 'bundler_esbuild', 'bundler_nft'],
async (options, t) => {
const FUNCTIONS_COUNT = 7
const FUNCTIONS_COUNT = 11
const { files } = await zipFixture(t, join('in-source-config', 'functions'), {
opts: options,
length: FUNCTIONS_COUNT,
Expand Down Expand Up @@ -2692,7 +2692,7 @@ test('listFunctions includes in-source config declarations', async (t) => {
const functions = await listFunctions(join(FIXTURES_DIR, 'in-source-config', 'functions'), {
parseISC: true,
})
const FUNCTIONS_COUNT = 7
const FUNCTIONS_COUNT = 11
t.is(functions.length, FUNCTIONS_COUNT)
functions.forEach((func) => {
t.is(func.schedule, '@daily')
Expand Down