diff --git a/packages/zip-it-and-ship-it/src/runtimes/node/in_source_config/index.ts b/packages/zip-it-and-ship-it/src/runtimes/node/in_source_config/index.ts index f72129ef60..0bd0dc2150 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/node/in_source_config/index.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/node/in_source_config/index.ts @@ -22,7 +22,7 @@ import { ExtendedRoute, Route, getRoutes } from '../../../utils/routes.js' import { RUNTIME } from '../../runtime.js' import type { BindingMethod } from '../parser/bindings.js' import { createBindingsMethod } from '../parser/bindings.js' -import { traverseNodes } from '../parser/exports.js' +import { parseObject, 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' @@ -87,25 +87,38 @@ export const inSourceConfig = functionConfig export type InSourceConfig = z.infer /** - * Extracts event subscription slugs from the default export expression, - * if it's an object whose property names match known event handlers. + * Resolves the default export expression to an ObjectExpression if possible, + * following identifier bindings when needed. */ -const getEventSubscriptions = ( +const resolveObjectExpression = ( expression: Expression | Declaration | undefined, getAllBindings: BindingMethod, -): string[] => { - let objectExpression: ObjectExpression | undefined - +): ObjectExpression | undefined => { if (expression?.type === 'ObjectExpression') { - objectExpression = expression - } else if (expression?.type === 'Identifier') { + return expression + } + + if (expression?.type === 'Identifier') { const binding = getAllBindings().get(expression.name) if (binding?.type === 'ObjectExpression') { - objectExpression = binding + return binding } } + return undefined +} + +/** + * Extracts event subscription slugs from the default export expression, + * if it's an object whose property names match known event handlers. + */ +const getEventSubscriptions = ( + expression: Expression | Declaration | undefined, + getAllBindings: BindingMethod, +): string[] => { + const objectExpression = resolveObjectExpression(expression, getAllBindings) + if (!objectExpression) { return [] } @@ -130,6 +143,41 @@ const getEventSubscriptions = ( return events } +/** + * Extracts a `config` property from the default export object expression, + * returning it as a plain object. This supports patterns like: + * + * ```js + * export default { + * fetch() { ... }, + * config: { path: "/hello" } + * } + * ``` + */ +const getConfigFromDefaultExport = ( + expression: Expression | Declaration | undefined, + getAllBindings: BindingMethod, +): Record | undefined => { + const objectExpression = resolveObjectExpression(expression, getAllBindings) + + if (!objectExpression) { + return undefined + } + + for (const property of objectExpression.properties) { + if ( + property.type === 'ObjectProperty' && + property.key.type === 'Identifier' && + property.key.name === 'config' && + property.value.type === 'ObjectExpression' + ) { + return parseObject(property.value) + } + } + + return undefined +} + const validateScheduleFunction = (functionFound: boolean, scheduleFound: boolean, functionName: string): void => { if (!functionFound) { throw new FunctionBundlingUserError( @@ -205,7 +253,11 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration result.eventSubscriptions = eventSubscriptions } - const { data, error, success } = inSourceConfig.safeParse(configExport) + // Config from the default export object's `config` property is used as a + // fallback when no separate `export const config` exists. + const inlineConfig = getConfigFromDefaultExport(defaultExportExpression, getAllBindings) + const mergedConfigExport = Object.keys(configExport).length > 0 ? configExport : (inlineConfig ?? {}) + const { data, error, success } = inSourceConfig.safeParse(mergedConfigExport) if (success) { result.config = data diff --git a/packages/zip-it-and-ship-it/src/runtimes/node/parser/exports.ts b/packages/zip-it-and-ship-it/src/runtimes/node/parser/exports.ts index 928520dd75..ae5cb9f3f1 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/node/parser/exports.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/node/parser/exports.ts @@ -206,7 +206,7 @@ const parseConfigESMExport = (node: Statement) => { * subtree. Only values supported by the `parsePrimitive` method are returned, * and any others will be ignored and excluded from the resulting object. */ -const parseObject = (node: ObjectExpression) => +export const parseObject = (node: ObjectExpression) => node.properties.reduce( (acc, property): Record => { if (property.type === 'ObjectProperty' && property.key.type === 'Identifier') { diff --git a/packages/zip-it-and-ship-it/tests/unit/runtimes/node/in_source_config.test.ts b/packages/zip-it-and-ship-it/tests/unit/runtimes/node/in_source_config.test.ts index e61b542c2d..cd6ac3cd94 100644 --- a/packages/zip-it-and-ship-it/tests/unit/runtimes/node/in_source_config.test.ts +++ b/packages/zip-it-and-ship-it/tests/unit/runtimes/node/in_source_config.test.ts @@ -905,4 +905,57 @@ describe('V2 API', () => { runtimeAPIVersion: 2, }) }) + + describe('Inline config in default export object', () => { + test('Extracts config from the default export object', () => { + const source = `export default { + fetch() { return new Response("Hello") }, + config: { path: "/hello" } + }` + + const isc = parseSource(source, options) + + expect(isc.runtimeAPIVersion).toBe(2) + expect(isc.config).toEqual({ path: ['/hello'] }) + expect(isc.routes).toEqual([{ pattern: '/hello', literal: '/hello', methods: [], prefer_static: undefined }]) + expect(isc.eventSubscriptions).toEqual(['fetch']) + }) + + test('Extracts config from a binding-resolved default export object', () => { + const source = `const handlers = { + fetch() { return new Response("Hello") }, + config: { path: "/api" } + } + export default handlers` + + const isc = parseSource(source, options) + + expect(isc.runtimeAPIVersion).toBe(2) + expect(isc.config).toEqual({ path: ['/api'] }) + expect(isc.routes).toHaveLength(1) + }) + + test('Named config export takes precedence over inline config', () => { + const source = `export default { + fetch() { return new Response("Hello") }, + config: { path: "/inline" } + } + export const config = { path: "/named" }` + + const isc = parseSource(source, options) + + expect(isc.config).toEqual({ path: ['/named'] }) + }) + + test('Ignores non-object config property', () => { + const source = `export default { + fetch() { return new Response("Hello") }, + config: "not-an-object" + }` + + const isc = parseSource(source, options) + + expect(isc.config).toEqual({}) + }) + }) })