Skip to content

Commit

Permalink
feat: detect literal export as default as a v2 API (netlify/zip-it-an…
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasholzer authored Oct 11, 2023
1 parent 7f07031 commit a9cbea3
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 26 deletions.
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
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
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 packages/zip-it-and-ship-it/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
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

0 comments on commit a9cbea3

Please sign in to comment.