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

feat: detect literal export as default as a v2 API #1600

Merged
merged 1 commit into from
Oct 11, 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
3 changes: 2 additions & 1 deletion src/runtimes/node/in_source_config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,5 @@ export type ISCExportWithObject = {
object: Record<string, unknown>
}
export type ISCExportOther = { type: 'other' }
export type ISCExport = ISCExportWithCallExpression | ISCExportWithObject | ISCExportOther
export type ISCDefaultExport = { type: 'default' }
export type ISCExport = ISCExportWithCallExpression | ISCExportWithObject | ISCExportOther | ISCDefaultExport
4 changes: 3 additions & 1 deletion src/runtimes/node/in_source_config/properties/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isExpression } from '@babel/types'

import type { BindingMethod } from '../../parser/bindings.js'
import type { ISCHandlerArg } from '../index.js'

Expand All @@ -7,7 +9,7 @@ export const parse = ({ args }: { args: ISCHandlerArg[] }, getAllBindings: Bindi
if (expression.type === 'Identifier') {
const binding = getAllBindings().get(expression.name)

if (binding) {
if (binding && isExpression(binding)) {
expression = binding
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/runtimes/node/parser/bindings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Expression, Statement, VariableDeclaration } from '@babel/types'
import type { Declaration, Expression, Statement, VariableDeclaration } from '@babel/types'

type Bindings = Map<string, Expression>
type Bindings = Map<string, Expression | Declaration>

const getBindingFromVariableDeclaration = function (node: VariableDeclaration, bindings: Bindings): void {
node.declarations.forEach((declaration) => {
Expand All @@ -11,7 +11,9 @@ const getBindingFromVariableDeclaration = function (node: VariableDeclaration, b
}

const getBindingsFromNode = function (node: Statement, bindings: Bindings): void {
if (node.type === 'VariableDeclaration') {
if (node.type === 'FunctionDeclaration' && node.id?.name) {
bindings.set(node.id.name, node)
} else if (node.type === 'VariableDeclaration') {
// A variable was created, so create it and store the potential value
getBindingFromVariableDeclaration(node, bindings)
} else if (
Expand Down
31 changes: 29 additions & 2 deletions src/runtimes/node/parser/exports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
Declaration,
ExportDefaultDeclaration,
ExportNamedDeclaration,
ExportSpecifier,
Expand Down Expand Up @@ -40,6 +41,11 @@ export const traverseNodes = (nodes: Statement[], getAllBindings: BindingMethod)
const esmHandlerExports = getNamedESMExport(node, 'handler', getAllBindings)

if (esmHandlerExports.length !== 0) {
if (esmHandlerExports.some(({ type }) => type === 'default')) {
hasDefaultExport = true

return
}
handlerExports.push(...esmHandlerExports)

return
Expand Down Expand Up @@ -132,6 +138,16 @@ const getNamedESMExport = (node: Statement, name: string, getAllBindings: Bindin
return exports
}

/**
* Check if the node is an `ExportSpecifier` that has a identifier with a default export:
* - `export { x as default }`
*/
const isDefaultExport = (node: ExportNamedDeclaration['specifiers'][number]): node is ExportSpecifier => {
const { type, exported } = node

return type === 'ExportSpecifier' && exported.type === 'Identifier' && exported.name === 'default'
}

/**
* Check if the node is an `ExportSpecifier` that has a named export with
* the given name, either as:
Expand Down Expand Up @@ -234,20 +250,31 @@ const getExportsFromBindings = (
specifiers: ExportNamedDeclaration['specifiers'],
name: string,
getAllBindings: BindingMethod,
) => {
): ISCExport[] => {
const defaultExport = specifiers.find((node) => isDefaultExport(node))

if (defaultExport && defaultExport.type === 'ExportSpecifier') {
const binding = getAllBindings().get(defaultExport.local.name)

if (binding?.type === 'ArrowFunctionExpression' || binding?.type === 'FunctionDeclaration') {
return [{ type: 'default' }]
}
}

const specifier = specifiers.find((node) => isNamedExport(node, name))

if (!specifier || specifier.type !== 'ExportSpecifier') {
return []
}

const binding = getAllBindings().get(specifier.local.name)

const exports = getExportsFromExpression(binding)

return exports
}

const getExportsFromExpression = (node: Expression | undefined | null): ISCExport[] => {
const getExportsFromExpression = (node: Expression | Declaration | undefined | null): ISCExport[] => {
switch (node?.type) {
case 'CallExpression': {
const { arguments: args, callee } = node
Expand Down
85 changes: 66 additions & 19 deletions tests/unit/runtimes/node/in_source_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('`schedule` helper', () => {
const source = `import { schedule } from "@netlify/functions"

const handler = schedule("@daily", () => {})

export { handler }`

const isc = parseSource(source, options)
Expand Down Expand Up @@ -140,7 +140,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const handler = function () { return { statusCode: 200, body: "Hello!" } }`

const isc = parseSource(source, options)
Expand All @@ -150,14 +150,61 @@ describe('V2 API', () => {

test('ESM file with no default export and a `handler` export', () => {
const source = `const handler = async () => ({ statusCode: 200, body: "Hello" })

export { handler }`

const isc = parseSource(source, options)

expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 })
})

test('ESM file with default exporting a function', () => {
const source = `
const handler = async () => ({ statusCode: 200, body: "Hello" })
export default handler;`

const isc = parseSource(source, options)
expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 })
})

test('ESM file with default export of variable and separate handler export', () => {
const source = `
const foo = 'foo'
export default foo;
export const handler = () => ({ statusCode: 200, body: "Hello" })`

const isc = parseSource(source, options)
expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 })
})

test('ESM file with default export wrapped in a literal from an arrow function', () => {
const source = `
const handler = async () => ({ statusCode: 200, body: "Hello" })
export const config = { schedule: "@daily" }
export { handler as default };`

const isc = parseSource(source, options)
expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], schedule: '@daily', runtimeAPIVersion: 2 })
})

test('ESM file with default export wrapped in a literal from a function', () => {
const source = `
async function handler(){ return { statusCode: 200, body: "Hello" }}
export { handler as default };`

const isc = parseSource(source, options)
expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 })
})

test('ESM file with default export exporting a constant', () => {
const source = `
const foo = "bar"
export { foo as default };`

const isc = parseSource(source, options)
expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 })
})

test('TypeScript file with a default export and no `handler` export', () => {
const source = `export default async (req: Request) => {
return new Response("Hello!")
Expand All @@ -172,7 +219,7 @@ describe('V2 API', () => {
const source = `exports.default = async () => {
return new Response("Hello!")
}

exports.handler = async () => ({ statusCode: 200, body: "Hello!" })`

const isc = parseSource(source, options)
Expand All @@ -196,7 +243,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
schedule: "@daily"
}`
Expand All @@ -212,7 +259,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
method: ["GET", "POST"]
}`
Expand All @@ -226,7 +273,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
method: "GET"
}`
Expand All @@ -246,7 +293,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: "missing-slash"
}`
Expand All @@ -269,7 +316,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: "/products("
}`
Expand All @@ -292,7 +339,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: {
url: "/products"
Expand All @@ -317,7 +364,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: ["/store", "/products("]
}`
Expand All @@ -340,7 +387,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: ["/store", 42]
}`
Expand All @@ -363,7 +410,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: ["/store", null]
}`
Expand All @@ -386,7 +433,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: ["/store", undefined]
}`
Expand All @@ -408,7 +455,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: "/products"
}`
Expand All @@ -422,7 +469,7 @@ describe('V2 API', () => {
const source = `exports.default = async () => {
return new Response("Hello!")
}

exports.config = {
path: "/products"
}`
Expand All @@ -437,7 +484,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: "/store/:category/products/:product-id"
}`
Expand All @@ -457,7 +504,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: [
"/store/:category/products/:product-id",
Expand Down Expand Up @@ -487,7 +534,7 @@ describe('V2 API', () => {
const source = `export default async () => {
return new Response("Hello!")
}

export const config = {
path: ["/products", "/products"]
}`
Expand Down