diff --git a/package-lock.json b/package-lock.json index c5034b2fc8..7b8a01cd75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26576,6 +26576,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/build": { "name": "@netlify/build", "version": "29.47.3", @@ -27880,7 +27888,8 @@ "toml": "^3.0.0", "unixify": "^1.0.0", "urlpattern-polyfill": "8.0.2", - "yargs": "^17.0.0" + "yargs": "^17.0.0", + "zod": "^3.23.8" }, "bin": { "zip-it-and-ship-it": "bin.js" @@ -27902,7 +27911,7 @@ "cardinal": "2.1.1", "cpy": "9.0.1", "decompress": "4.2.1", - "deepmerge": "4.3.1", + "deepmerge": "^4.3.1", "get-stream": "8.0.1", "is-ci": "3.0.1", "lambda-local": "2.2.0", diff --git a/packages/zip-it-and-ship-it/package.json b/packages/zip-it-and-ship-it/package.json index 6bbb23d142..f1167a9052 100644 --- a/packages/zip-it-and-ship-it/package.json +++ b/packages/zip-it-and-ship-it/package.json @@ -73,7 +73,8 @@ "toml": "^3.0.0", "unixify": "^1.0.0", "urlpattern-polyfill": "8.0.2", - "yargs": "^17.0.0" + "yargs": "^17.0.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/archiver": "5.3.4", @@ -92,7 +93,7 @@ "cardinal": "2.1.1", "cpy": "9.0.1", "decompress": "4.2.1", - "deepmerge": "4.3.1", + "deepmerge": "^4.3.1", "get-stream": "8.0.1", "is-ci": "3.0.1", "lambda-local": "2.2.0", diff --git a/packages/zip-it-and-ship-it/src/config.ts b/packages/zip-it-and-ship-it/src/config.ts index 21109a192b..c8936ee642 100644 --- a/packages/zip-it-and-ship-it/src/config.ts +++ b/packages/zip-it-and-ship-it/src/config.ts @@ -3,32 +3,35 @@ import { basename, extname, dirname, join } from 'path' import isPathInside from 'is-path-inside' import mergeOptions from 'merge-options' +import { z } from 'zod' import { FunctionSource } from './function.js' -import type { NodeBundlerName } from './runtimes/node/bundlers/types.js' -import type { ModuleFormat } from './runtimes/node/utils/module_format.js' +import { nodeBundler } from './runtimes/node/bundlers/types.js' +import { moduleFormat } from './runtimes/node/utils/module_format.js' import { minimatch } from './utils/matching.js' -interface FunctionConfig { - externalNodeModules?: string[] - includedFiles?: string[] - includedFilesBasePath?: string - ignoredNodeModules?: string[] - nodeBundler?: NodeBundlerName - nodeSourcemap?: boolean - nodeVersion?: string - rustTargetDirectory?: string - schedule?: string - zipGo?: boolean - name?: string - generator?: string - timeout?: number +export const functionConfig = z.object({ + externalNodeModules: z.array(z.string()).optional().catch([]), + generator: z.string().optional().catch(undefined), + includedFiles: z.array(z.string()).optional().catch([]), + includedFilesBasePath: z.string().optional().catch(undefined), + ignoredNodeModules: z.array(z.string()).optional().catch([]), + name: z.string().optional().catch(undefined), + nodeBundler: nodeBundler.optional().catch(undefined), + nodeSourcemap: z.boolean().optional().catch(undefined), + nodeVersion: z.string().optional().catch(undefined), + rustTargetDirectory: z.string().optional().catch(undefined), + schedule: z.string().optional().catch(undefined), + timeout: z.number().optional().catch(undefined), + zipGo: z.boolean().optional().catch(undefined), // Temporary configuration property, only meant to be used by the deploy // configuration API. Once we start emitting ESM files for all ESM functions, // we can remove this. - nodeModuleFormat?: ModuleFormat -} + nodeModuleFormat: moduleFormat.optional().catch(undefined), +}) + +type FunctionConfig = z.infer interface FunctionConfigFile { config: FunctionConfig diff --git a/packages/zip-it-and-ship-it/src/main.ts b/packages/zip-it-and-ship-it/src/main.ts index 7ae4afdcf3..979708fb43 100644 --- a/packages/zip-it-and-ship-it/src/main.ts +++ b/packages/zip-it-and-ship-it/src/main.ts @@ -17,7 +17,7 @@ export { ArchiveFormat, ARCHIVE_FORMAT } from './archive.js' export { NodeBundlerName, NODE_BUNDLER } from './runtimes/node/bundlers/types.js' export { RuntimeName, RUNTIME } from './runtimes/runtime.js' export { ModuleFormat, MODULE_FORMAT } from './runtimes/node/utils/module_format.js' -export { TrafficRules, Manifest } from './manifest.js' +export { Manifest } from './manifest.js' export { FunctionResult } from './utils/format_result.js' export interface ListedFunction { @@ -157,7 +157,7 @@ const getListedFunction = function ({ name, runtime: runtime.name, runtimeAPIVersion: staticAnalysisResult ? staticAnalysisResult?.runtimeAPIVersion ?? 1 : undefined, - schedule: staticAnalysisResult?.schedule ?? config.schedule, + schedule: staticAnalysisResult?.config?.schedule ?? config.schedule, inputModuleFormat: staticAnalysisResult?.inputModuleFormat, } } diff --git a/packages/zip-it-and-ship-it/src/manifest.ts b/packages/zip-it-and-ship-it/src/manifest.ts index 76a7a2e6b8..635d5c33f4 100644 --- a/packages/zip-it-and-ship-it/src/manifest.ts +++ b/packages/zip-it-and-ship-it/src/manifest.ts @@ -3,28 +3,10 @@ import { resolve } from 'path' import { arch, platform } from 'process' import type { InvocationMode } from './function.js' +import type { TrafficRules } from './rate_limit.js' import type { FunctionResult } from './utils/format_result.js' import type { Route } from './utils/routes.js' -export interface TrafficRules { - action: { - type: string - config: { - rateLimitConfig: { - algorithm: string - windowSize: number - windowLimit: number - } - aggregate: { - keys: { - type: string - }[] - } - to?: string - } - } -} - interface ManifestFunction { buildData?: Record invocationMode?: InvocationMode diff --git a/packages/zip-it-and-ship-it/src/rate_limit.ts b/packages/zip-it-and-ship-it/src/rate_limit.ts index 3e1ca721ae..8a8713e2af 100644 --- a/packages/zip-it-and-ship-it/src/rate_limit.ts +++ b/packages/zip-it-and-ship-it/src/rate_limit.ts @@ -1,30 +1,69 @@ -export enum RateLimitAlgorithm { - SlidingWindow = 'sliding_window', -} +import { z } from 'zod' -export enum RateLimitAggregator { - Domain = 'domain', - IP = 'ip', +export interface TrafficRules { + action: { + type: string + config: { + rateLimitConfig: { + algorithm: string + windowSize: number + windowLimit: number + } + aggregate: { + keys: { + type: string + }[] + } + to?: string + } + } } -export enum RateLimitAction { - Limit = 'rate_limit', - Rewrite = 'rewrite', -} +const rateLimitAction = z.enum(['rate_limit', 'rewrite']) +const rateLimitAlgorithm = z.enum(['sliding_window']) +const rateLimitAggregator = z.enum(['domain', 'ip']) +const slidingWindow = z.object({ + windowLimit: z.number(), + windowSize: z.number(), +}) +const rewriteActionConfig = z.object({ + to: z.string(), +}) -interface SlidingWindow { - windowLimit: number - windowSize: number -} +export const rateLimit = z + .object({ + action: rateLimitAction.optional(), + aggregateBy: rateLimitAggregator.or(z.array(rateLimitAggregator)).optional(), + algorithm: rateLimitAlgorithm.optional(), + }) + .merge(slidingWindow) + .merge(rewriteActionConfig.partial()) -export type RewriteActionConfig = SlidingWindow & { - to: string -} +type RateLimit = z.infer -interface RateLimitConfig { - action?: RateLimitAction - aggregateBy?: RateLimitAggregator | RateLimitAggregator[] - algorithm?: RateLimitAlgorithm -} +/** + * Takes a rate limiting configuration object and returns a traffic rules + * object that is added to the manifest. + */ +export const getTrafficRulesConfig = (input: RateLimit): TrafficRules | undefined => { + const { windowSize, windowLimit, algorithm, aggregateBy, action, to } = input + const rateLimitAgg = Array.isArray(aggregateBy) ? aggregateBy : [rateLimitAggregator.Enum.domain] + const rewriteConfig = to ? { to: input.to } : undefined -export type RateLimit = RateLimitConfig & (SlidingWindow | RewriteActionConfig) + return { + action: { + type: action || rateLimitAction.Enum.rate_limit, + config: { + ...rewriteConfig, + rateLimitConfig: { + windowLimit, + windowSize, + algorithm: algorithm || rateLimitAlgorithm.Enum.sliding_window, + }, + aggregate: { + keys: rateLimitAgg.map((agg) => ({ type: agg })), + }, + }, + }, + } +} diff --git a/packages/zip-it-and-ship-it/src/runtimes/node/bundlers/types.ts b/packages/zip-it-and-ship-it/src/runtimes/node/bundlers/types.ts index 7749347bbc..f31312e4a0 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/node/bundlers/types.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/node/bundlers/types.ts @@ -1,9 +1,9 @@ import type { Message } from 'esbuild' +import { z } from 'zod' import type { FunctionConfig } from '../../../config.js' import type { FeatureFlags } from '../../../feature_flags.js' import type { FunctionSource } from '../../../function.js' -import { ObjectValues } from '../../../types/utils.js' import type { RuntimeCache } from '../../../utils/cache.js' import { Logger } from '../../../utils/logger.js' import type { ModuleFormat } from '../utils/module_format.js' @@ -16,7 +16,9 @@ export const NODE_BUNDLER = { NONE: 'none', } as const -export type NodeBundlerName = ObjectValues +export const nodeBundler = z.nativeEnum(NODE_BUNDLER) + +export type NodeBundlerName = z.infer // TODO: Create a generic warning type type BundlerWarning = Message 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 5b9e0f51c5..394a0de7b5 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 @@ -1,13 +1,13 @@ import type { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types' +import mergeOptions from 'merge-options' +import { z } from 'zod' +import { FunctionConfig, functionConfig } from '../../../config.js' import { InvocationMode, INVOCATION_MODE } from '../../../function.js' -import { TrafficRules } from '../../../manifest.js' -import { RateLimitAction, RateLimitAggregator, RateLimitAlgorithm } from '../../../rate_limit.js' +import { rateLimit } from '../../../rate_limit.js' import { FunctionBundlingUserError } from '../../../utils/error.js' -import { nonNullable } from '../../../utils/non_nullable.js' -import { getRoutes, Route } from '../../../utils/routes.js' +import { Route, getRoutes } from '../../../utils/routes.js' import { RUNTIME } from '../../runtime.js' -import { NODE_BUNDLER } from '../bundlers/types.js' import { createBindingsMethod } from '../parser/bindings.js' import { traverseNodes } from '../parser/exports.js' import { getImports } from '../parser/imports.js' @@ -18,19 +18,11 @@ import { parse as parseSchedule } from './properties/schedule.js' export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions' -export type ISCValues = { - routes?: Route[] - schedule?: string - methods?: string[] - trafficRules?: TrafficRules - name?: string - generator?: string - timeout?: number -} - -export interface StaticAnalysisResult extends ISCValues { +export interface StaticAnalysisResult { + config: InSourceConfig inputModuleFormat?: ModuleFormat invocationMode?: InvocationMode + routes?: Route[] runtimeAPIVersion?: number } @@ -38,6 +30,43 @@ interface FindISCDeclarationsOptions { functionName: string } +const ensureArray = (input: unknown) => (Array.isArray(input) ? input : [input]) + +const httpMethods = z.preprocess( + (input) => (typeof input === 'string' ? input.toUpperCase() : input), + z.enum(['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE', 'HEAD']), +) +const path = z.string().startsWith('/', { message: "Must start with a '/'" }) + +export const inSourceConfig = functionConfig + .pick({ + externalNodeModules: true, + generator: true, + includedFiles: true, + ignoredNodeModules: true, + name: true, + nodeBundler: true, + nodeVersion: true, + schedule: true, + timeout: true, + }) + .extend({ + method: z + .union([httpMethods, z.array(httpMethods)], { + errorMap: () => ({ message: 'Must be a string or array of strings' }), + }) + .transform(ensureArray) + .optional(), + path: z + .union([path, z.array(path)], { errorMap: () => ({ message: 'Must be a string or array of strings' }) }) + .transform(ensureArray) + .optional(), + preferStatic: z.boolean().optional().catch(undefined), + rateLimit: rateLimit.optional().catch(undefined), + }) + +export type InSourceConfig = z.infer + const validateScheduleFunction = (functionFound: boolean, scheduleFound: boolean, functionName: string): void => { if (!functionFound) { throw new FunctionBundlingUserError( @@ -54,83 +83,6 @@ const validateScheduleFunction = (functionFound: boolean, scheduleFound: boolean } } -/** - * Normalizes method names into arrays of uppercase strings. - * (e.g. "get" becomes ["GET"]) - */ -const normalizeMethods = (input: unknown, name: string): string[] | undefined => { - const methods = Array.isArray(input) ? input : [input] - - return methods.map((method) => { - if (typeof method !== 'string') { - throw new FunctionBundlingUserError( - `Could not parse method declaration of function '${name}'. Expecting HTTP Method, got ${method}`, - { - functionName: name, - runtime: RUNTIME.JAVASCRIPT, - bundler: NODE_BUNDLER.ESBUILD, - }, - ) - } - - return method.toUpperCase() - }) -} - -/** - * Extracts the `ratelimit` configuration from the exported config. - */ -const getTrafficRulesConfig = (input: unknown, name: string): TrafficRules | undefined => { - if (typeof input !== 'object' || input === null) { - throw new FunctionBundlingUserError( - `Could not parse ratelimit declaration of function '${name}'. Expecting an object, got ${input}`, - { - functionName: name, - runtime: RUNTIME.JAVASCRIPT, - bundler: NODE_BUNDLER.ESBUILD, - }, - ) - } - - const { windowSize, windowLimit, algorithm, aggregateBy, action } = input as Record - - if ( - typeof windowSize !== 'number' || - typeof windowLimit !== 'number' || - !Number.isInteger(windowSize) || - !Number.isInteger(windowLimit) - ) { - throw new FunctionBundlingUserError( - `Could not parse ratelimit declaration of function '${name}'. Expecting 'windowSize' and 'limitSize' integer properties, got ${input}`, - { - functionName: name, - runtime: RUNTIME.JAVASCRIPT, - bundler: NODE_BUNDLER.ESBUILD, - }, - ) - } - - const rateLimitAgg = Array.isArray(aggregateBy) ? aggregateBy : [RateLimitAggregator.Domain] - const rewriteConfig = 'to' in input && typeof input.to === 'string' ? { to: input.to } : undefined - - return { - action: { - type: (action as RateLimitAction) || RateLimitAction.Limit, - config: { - ...rewriteConfig, - rateLimitConfig: { - windowLimit, - windowSize, - algorithm: (algorithm as RateLimitAlgorithm) || RateLimitAlgorithm.SlidingWindow, - }, - aggregate: { - keys: rateLimitAgg.map((agg) => ({ type: agg })), - }, - }, - }, - } -} - /** * 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. @@ -142,7 +94,9 @@ export const parseFile = async ( const source = await safelyReadSource(sourcePath) if (source === null) { - return {} + return { + config: {}, + } } return parseSource(source, { functionName }) @@ -157,7 +111,9 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration const ast = safelyParseSource(source) if (ast === null) { - return {} + return { + config: {}, + } } const imports = ast.body.flatMap((node) => getImports(node, IN_SOURCE_CONFIG_MODULE)) @@ -172,92 +128,97 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration if (isV2API) { const result: StaticAnalysisResult = { + config: {}, inputModuleFormat, runtimeAPIVersion: 2, } + const { data, error, success } = inSourceConfig.safeParse(configExport) + + if (success) { + result.config = data + result.routes = getRoutes({ + functionName, + methods: data.method ?? [], + path: data.path, + preferStatic: data.preferStatic, + }) + } else { + // TODO: Handle multiple errors. + const [issue] = error.issues - if (typeof configExport.schedule === 'string') { - result.schedule = configExport.schedule - } - - if (typeof configExport.name === 'string') { - result.name = configExport.name + throw new FunctionBundlingUserError( + `Function ${functionName} has a configuration error on '${issue.path.join('.')}': ${issue.message}`, + { + functionName, + runtime: RUNTIME.JAVASCRIPT, + }, + ) } - if (typeof configExport.generator === 'string') { - result.generator = configExport.generator - } + return result + } - if (typeof configExport.timeout === 'number') { - result.timeout = configExport.timeout - } + const result: StaticAnalysisResult = { + config: {}, + inputModuleFormat, + runtimeAPIVersion: 1, + } - if (configExport.method !== undefined) { - result.methods = normalizeMethods(configExport.method, functionName) + handlerExports.forEach((node) => { + // We're only interested in exports with call expressions, since that's + // the pattern we use for the wrapper functions. + if (node.type !== 'call-expression') { + return } - result.routes = getRoutes({ - functionName, - methods: result.methods ?? [], - path: configExport.path, - preferStatic: configExport.preferStatic === true, - }) + const { args, local: exportName } = node + const matchingImport = imports.find(({ local: importName }) => importName === exportName) - if (configExport.rateLimit !== undefined) { - result.trafficRules = getTrafficRulesConfig(configExport.rateLimit, functionName) + if (matchingImport === undefined) { + return } - return result - } + switch (matchingImport.imported) { + case 'schedule': { + const parsed = parseSchedule({ args }, getAllBindings) - const iscExports = handlerExports - .map((node) => { - // We're only interested in exports with call expressions, since that's - // the pattern we use for the wrapper functions. - if (node.type !== 'call-expression') { - return null - } + scheduledFunctionFound = true + if (parsed.schedule) { + scheduleFound = true + } - const { args, local: exportName } = node - const matchingImport = imports.find(({ local: importName }) => importName === exportName) + if (parsed.schedule !== undefined) { + result.config.schedule = parsed.schedule + } - if (matchingImport === undefined) { - return null + return } - switch (matchingImport.imported) { - case 'schedule': { - const parsed = parseSchedule({ args }, getAllBindings) - - scheduledFunctionFound = true - if (parsed.schedule) { - scheduleFound = true - } + case 'stream': { + result.invocationMode = INVOCATION_MODE.Stream - return parsed - } - - case 'stream': { - return { - invocationMode: INVOCATION_MODE.Stream, - } - } - - default: - // no-op + return } - return null - }) - .filter(nonNullable) + default: + // no-op + } + + return + }) if (scheduledFunctionExpected) { validateScheduleFunction(scheduledFunctionFound, scheduleFound, functionName) } - const mergedExports: ISCValues = iscExports.reduce((acc, obj) => ({ ...acc, ...obj }), {}) + return result +} - return { ...mergedExports, inputModuleFormat, runtimeAPIVersion: 1 } +export const augmentFunctionConfig = ( + tomlConfig: FunctionConfig, + inSourceConfig: InSourceConfig = {}, +): FunctionConfig & InSourceConfig => { + return mergeOptions.call({ concatArrays: true }, tomlConfig, inSourceConfig) } export type ISCHandlerArg = ArgumentPlaceholder | Expression | SpreadElement | JSXNamespacedName diff --git a/packages/zip-it-and-ship-it/src/runtimes/node/index.ts b/packages/zip-it-and-ship-it/src/runtimes/node/index.ts index 6502860790..69ed26e594 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/node/index.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/node/index.ts @@ -4,13 +4,14 @@ import { copyFile } from 'cp-file' import { INVOCATION_MODE } from '../../function.js' import { Priority } from '../../priority.js' +import { getTrafficRulesConfig } from '../../rate_limit.js' import getInternalValue from '../../utils/get_internal_value.js' import { GetSrcFilesFunction, Runtime, RUNTIME, ZipFunction } from '../runtime.js' import { getBundler, getBundlerName } from './bundlers/index.js' import { NODE_BUNDLER } from './bundlers/types.js' import { findFunctionsInPaths, findFunctionInPath } from './finder.js' -import { parseFile } from './in_source_config/index.js' +import { augmentFunctionConfig, 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' @@ -64,10 +65,10 @@ const zipFunction: ZipFunction = async function ({ const staticAnalysisResult = await parseFile(mainFile, { functionName: name }) const runtimeAPIVersion = staticAnalysisResult.runtimeAPIVersion === 2 ? 2 : 1 - + const mergedConfig = augmentFunctionConfig(config, staticAnalysisResult.config) const pluginsModulesPath = await getPluginsModulesPath(srcDir) const bundlerName = await getBundlerName({ - config, + config: mergedConfig, extension, featureFlags, mainFile, @@ -89,7 +90,7 @@ const zipFunction: ZipFunction = async function ({ } = await bundler.bundle({ basePath, cache, - config, + config: mergedConfig, extension, featureFlags, filename, @@ -141,25 +142,19 @@ const zipFunction: ZipFunction = async function ({ invocationMode = INVOCATION_MODE.Background } - const { - trafficRules, - generator: staticAnalysisGenerator, - name: staticAnalysisName, - timeout: staticAnalysisTimeout, - } = staticAnalysisResult - const outputModuleFormat = extname(finalMainFile) === MODULE_FILE_EXTENSION.MJS ? MODULE_FORMAT.ESM : MODULE_FORMAT.COMMONJS const priority = isInternal ? Priority.GeneratedFunction : Priority.UserFunction + const trafficRules = mergedConfig?.rateLimit ? getTrafficRulesConfig(mergedConfig.rateLimit) : undefined return { bundler: bundlerName, bundlerWarnings, - config, - displayName: staticAnalysisName || config?.name, + config: mergedConfig, + displayName: mergedConfig?.name, entryFilename: zipPath.entryFilename, - generator: staticAnalysisGenerator || config?.generator || getInternalValue(isInternal), - timeout: staticAnalysisTimeout || config?.timeout, + generator: mergedConfig?.generator || getInternalValue(isInternal), + timeout: mergedConfig?.timeout, inputs, includedFiles, staticAnalysisResult, @@ -170,7 +165,9 @@ const zipFunction: ZipFunction = async function ({ priority, trafficRules, runtimeVersion: - runtimeAPIVersion === 2 ? getNodeRuntimeForV2(config.nodeVersion) : getNodeRuntime(config.nodeVersion), + runtimeAPIVersion === 2 + ? getNodeRuntimeForV2(mergedConfig.nodeVersion) + : getNodeRuntime(mergedConfig.nodeVersion), } } diff --git a/packages/zip-it-and-ship-it/src/runtimes/node/utils/module_format.ts b/packages/zip-it-and-ship-it/src/runtimes/node/utils/module_format.ts index a51a0ae4d6..dcf9a3450f 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/node/utils/module_format.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/node/utils/module_format.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' + import type { FeatureFlags } from '../../../feature_flags.js' import { ObjectValues } from '../../../types/utils.js' @@ -8,7 +10,9 @@ export const MODULE_FORMAT = { ESM: 'esm', } as const -export type ModuleFormat = ObjectValues +export const moduleFormat = z.nativeEnum(MODULE_FORMAT) + +export type ModuleFormat = z.infer export const MODULE_FILE_EXTENSION = { CJS: '.cjs', diff --git a/packages/zip-it-and-ship-it/src/runtimes/runtime.ts b/packages/zip-it-and-ship-it/src/runtimes/runtime.ts index 9a9c830b22..e527c97adc 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/runtime.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/runtime.ts @@ -3,7 +3,7 @@ import type { FunctionConfig } from '../config.js' import type { FeatureFlags } from '../feature_flags.js' import type { FunctionSource, InvocationMode, SourceFile } from '../function.js' import type { ModuleFormat } from '../main.js' -import { TrafficRules } from '../manifest.js' +import type { TrafficRules } from '../rate_limit.js' import { ObjectValues } from '../types/utils.js' import type { RuntimeCache } from '../utils/cache.js' import { Logger } from '../utils/logger.js' diff --git a/packages/zip-it-and-ship-it/src/utils/format_result.ts b/packages/zip-it-and-ship-it/src/utils/format_result.ts index 5b40c185b9..fdc42622d8 100644 --- a/packages/zip-it-and-ship-it/src/utils/format_result.ts +++ b/packages/zip-it-and-ship-it/src/utils/format_result.ts @@ -18,7 +18,7 @@ export const formatZipResult = (archive: FunctionArchive) => { staticAnalysisResult: undefined, routes: archive.staticAnalysisResult?.routes, runtime: archive.runtime.name, - schedule: archive.staticAnalysisResult?.schedule ?? archive?.config?.schedule, + schedule: archive.staticAnalysisResult?.config?.schedule ?? archive?.config?.schedule, runtimeAPIVersion: archive.staticAnalysisResult?.runtimeAPIVersion, } diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/function.js b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/function.js new file mode 100644 index 0000000000..cd3911dead --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/function.js @@ -0,0 +1,10 @@ +export default async () => + new Response('

Hello world

', { + headers: { + 'content-type': 'text/html', + }, + }) + +export const config = { + nodeBundler: 'none', +} diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/package.json b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/author1.md b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/author1.md new file mode 100644 index 0000000000..057084e918 --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/author1.md @@ -0,0 +1 @@ +Author 1 diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post1.md b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post1.md new file mode 100644 index 0000000000..40b4532fed --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post1.md @@ -0,0 +1 @@ +Post 1 diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post2.md b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post2.md new file mode 100644 index 0000000000..8c38f6aa5a --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post2.md @@ -0,0 +1 @@ +Post 2 diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/function.js b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/function.js new file mode 100644 index 0000000000..c9964292d1 --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/function.js @@ -0,0 +1,10 @@ +export default async () => + new Response('

Hello world

', { + headers: { + 'content-type': 'text/html', + }, + }) + +export const config = { + includedFiles: ['blog/author*'], +} diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/package.json b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/function.js b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/function.js new file mode 100644 index 0000000000..9fc3aee37c --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/function.js @@ -0,0 +1,10 @@ +export default async () => + new Response('

Hello world

', { + headers: { + 'content-type': 'text/html', + }, + }) + +export const config = { + nodeVersion: '20', +} diff --git a/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/package.json b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} 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 19b031525f..963f1c248d 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 @@ -13,7 +13,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'cjs', schedule: '@daily', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: { schedule: '@daily' }, inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) }) test('CommonJS file with `schedule` helper renamed locally', () => { @@ -23,7 +23,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'cjs', schedule: '@daily', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: { schedule: '@daily' }, inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) }) test('CommonJS file importing from a package other than "@netlify/functions"', () => { @@ -33,7 +33,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) }) test.todo('CommonJS file with `schedule` helper exported from a variable', () => { @@ -45,7 +45,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'cjs', schedule: '@daily', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: { schedule: '@daily' }, inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) }) test('ESM file with `schedule` helper', () => { @@ -55,7 +55,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', schedule: '@daily', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: { schedule: '@daily' }, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) test('ESM file with `schedule` helper renamed locally', () => { @@ -65,7 +65,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', schedule: '@daily', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: { schedule: '@daily' }, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) test('ESM file importing from a package other than "@netlify/functions"', () => { @@ -75,7 +75,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) test('ESM file with `handler` exported from a variable', () => { @@ -87,7 +87,7 @@ describe('`schedule` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', schedule: '@daily', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: { schedule: '@daily' }, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) }) @@ -101,7 +101,7 @@ describe('`stream` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', invocationMode: 'stream', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', invocationMode: 'stream', runtimeAPIVersion: 1 }) }) test('CommonJS file importing from a package other than "@netlify/functions"', () => { @@ -111,7 +111,7 @@ describe('`stream` helper', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) }) @@ -128,7 +128,7 @@ describe('V2 API', () => { }` const isc = parseSource(source, { ...options }) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) }) test('ESM file with a default export and a `handler` export', () => { @@ -140,7 +140,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) test('ESM file with no default export and a `handler` export', () => { @@ -150,7 +150,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) test('ESM file with default exporting a function', () => { @@ -159,7 +159,7 @@ describe('V2 API', () => { export default handler;` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) }) test('ESM file with default export of variable and separate handler export', () => { @@ -169,7 +169,7 @@ describe('V2 API', () => { export const handler = () => ({ statusCode: 200, body: "Hello" })` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', runtimeAPIVersion: 1 }) }) test('ESM file with default export wrapped in a literal from an arrow function', () => { @@ -179,7 +179,12 @@ describe('V2 API', () => { export { handler as default };` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], schedule: '@daily', runtimeAPIVersion: 2 }) + expect(isc).toEqual({ + config: { schedule: '@daily' }, + inputModuleFormat: 'esm', + routes: [], + runtimeAPIVersion: 2, + }) }) test('ESM file with separate config export', () => { @@ -190,7 +195,12 @@ describe('V2 API', () => { export default handler ` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], schedule: '@daily', runtimeAPIVersion: 2 }) + expect(isc).toEqual({ + config: { schedule: '@daily' }, + inputModuleFormat: 'esm', + routes: [], + runtimeAPIVersion: 2, + }) }) test('ESM file with default export and named export', () => { @@ -200,7 +210,12 @@ describe('V2 API', () => { export { handler as default, config };` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], schedule: '@daily', runtimeAPIVersion: 2 }) + expect(isc).toEqual({ + config: { schedule: '@daily' }, + inputModuleFormat: 'esm', + routes: [], + runtimeAPIVersion: 2, + }) }) // This is the Remix handler @@ -220,6 +235,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) expect(isc).toEqual({ + config: { path: ['/*'] }, inputModuleFormat: 'esm', routes: [ { @@ -262,7 +278,7 @@ describe('V2 API', () => { export { handler as default };` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) }) test('ESM file with default export exporting a constant', () => { @@ -271,7 +287,7 @@ describe('V2 API', () => { export { foo as default };` const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) }) test('TypeScript file with a default export and no `handler` export', () => { @@ -281,7 +297,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) }) test('CommonJS file with a default export and a `handler` export', () => { @@ -293,7 +309,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'cjs', runtimeAPIVersion: 1 }) }) test('CommonJS file with a default export and no `handler` export', () => { @@ -303,7 +319,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'cjs', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'cjs', routes: [], runtimeAPIVersion: 2 }) }) test('ESM file with a default export consisting of a function call', () => { @@ -314,7 +330,7 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) + expect(isc).toEqual({ config: {}, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2 }) }) }) @@ -330,7 +346,12 @@ describe('V2 API', () => { const isc = parseSource(source, options) - expect(isc).toEqual({ inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2, schedule: '@daily' }) + expect(isc).toEqual({ + config: { schedule: '@daily' }, + inputModuleFormat: 'esm', + routes: [], + runtimeAPIVersion: 2, + }) }) }) @@ -344,9 +365,9 @@ describe('V2 API', () => { method: ["GET", "POST"] }` - const { methods } = parseSource(source, options) + const { config } = parseSource(source, options) - expect(methods).toEqual(['GET', 'POST']) + expect(config?.method).toEqual(['GET', 'POST']) }) test('Using single method', () => { @@ -358,9 +379,9 @@ describe('V2 API', () => { method: "GET" }` - const { methods } = parseSource(source, options) + const { config } = parseSource(source, options) - expect(methods).toEqual(['GET']) + expect(config?.method).toEqual(['GET']) }) }) @@ -369,20 +390,20 @@ describe('V2 API', () => { test('Missing a leading slash', () => { expect.assertions(4) - try { - const source = `export default async () => { - return new Response("Hello!") - } + const source = `export default async () => { + return new Response("Hello!") + } - export const config = { - path: "missing-slash" - }` + export const config = { + path: "missing-slash" + }` + try { parseSource(source, options) } catch (error) { const { customErrorInfo, message } = error - expect(message).toBe(`'path' property must start with a '/'`) + expect(message).toBe(`Function func1 has a configuration error on 'path': Must start with a '/'`) expect(customErrorInfo.type).toBe('functionsBundling') expect(customErrorInfo.location.functionName).toBe('func1') expect(customErrorInfo.location.runtime).toBe('js') @@ -392,15 +413,15 @@ describe('V2 API', () => { test('An invalid pattern', () => { expect.assertions(4) - try { - const source = `export default async () => { - return new Response("Hello!") - } + const source = `export default async () => { + return new Response("Hello!") + } - export const config = { - path: "/products(" - }` + export const config = { + path: "/products(" + }` + try { parseSource(source, options) } catch (error) { const { customErrorInfo, message } = error @@ -415,22 +436,24 @@ describe('V2 API', () => { test('A non-string value', () => { expect.assertions(4) - try { - const source = `export default async () => { - return new Response("Hello!") - } + const source = `export default async () => { + return new Response("Hello!") + } - export const config = { - path: { - url: "/products" - } - }` + export const config = { + path: { + url: "/products" + } + }` + try { parseSource(source, options) } catch (error) { const { customErrorInfo, message } = error - expect(message).toBe(`'path' property must be a string, found '{"url":"/products"}'`) + expect(message).toBe( + `Function func1 has a configuration error on 'path': Must be a string or array of strings`, + ) expect(customErrorInfo.type).toBe('functionsBundling') expect(customErrorInfo.location.functionName).toBe('func1') expect(customErrorInfo.location.runtime).toBe('js') @@ -476,7 +499,9 @@ describe('V2 API', () => { } catch (error) { const { customErrorInfo, message } = error - expect(message).toBe(`'path' property must be a string, found '42'`) + expect(message).toBe( + `Function func1 has a configuration error on 'path': Must be a string or array of strings`, + ) expect(customErrorInfo.type).toBe('functionsBundling') expect(customErrorInfo.location.functionName).toBe('func1') expect(customErrorInfo.location.runtime).toBe('js') @@ -499,7 +524,9 @@ describe('V2 API', () => { } catch (error) { const { customErrorInfo, message } = error - expect(message).toBe(`'path' property must be a string, found 'null'`) + expect(message).toBe( + `Function func1 has a configuration error on 'path': Must be a string or array of strings`, + ) expect(customErrorInfo.type).toBe('functionsBundling') expect(customErrorInfo.location.functionName).toBe('func1') expect(customErrorInfo.location.runtime).toBe('js') @@ -522,7 +549,9 @@ describe('V2 API', () => { } catch (error) { const { customErrorInfo, message } = error - expect(message).toBe(`'path' property must be a string, found 'undefined'`) + expect(message).toBe( + `Function func1 has a configuration error on 'path': Must be a string or array of strings`, + ) expect(customErrorInfo.type).toBe('functionsBundling') expect(customErrorInfo.location.functionName).toBe('func1') expect(customErrorInfo.location.runtime).toBe('js') @@ -711,11 +740,10 @@ describe('V2 API', () => { const isc = parseSource(source, options) expect(isc).toEqual({ + config: { generator: 'bar@1.2.3', name: 'foo' }, inputModuleFormat: 'esm', - routes: [], runtimeAPIVersion: 2, - name: 'foo', - generator: 'bar@1.2.3', + routes: [], }) }) @@ -726,10 +754,10 @@ describe('V2 API', () => { const isc = parseSource(source, options) expect(isc).toEqual({ + config: { timeout: 60 }, inputModuleFormat: 'esm', routes: [], runtimeAPIVersion: 2, - timeout: 60, }) }) }) diff --git a/packages/zip-it-and-ship-it/tests/v2api.test.ts b/packages/zip-it-and-ship-it/tests/v2api.test.ts index 467d2369d3..acbf5b0462 100644 --- a/packages/zip-it-and-ship-it/tests/v2api.test.ts +++ b/packages/zip-it-and-ship-it/tests/v2api.test.ts @@ -10,6 +10,7 @@ import { dir as getTmpDir } from 'tmp-promise' import { afterEach, describe, expect, test, vi } from 'vitest' import { ARCHIVE_FORMAT } from '../src/archive.js' +import { DEFAULT_NODE_VERSION } from '../src/runtimes/node/utils/node_version.js' import { invokeLambda, readAsBuffer } from './helpers/lambda.js' import { zipFixture, unzipFiles, importFunctionFile, FIXTURES_ESM_DIR, FIXTURES_DIR } from './helpers/main.js' @@ -396,34 +397,42 @@ describe.runIf(semver.gte(nodeVersion, '18.13.0'))('V2 functions API', () => { }, }) - expect.assertions(files.length + 2) + expect(files.some(({ name }) => name === 'with-literal')).toBeTruthy() + expect(files.some(({ name }) => name === 'with-named-group')).toBeTruthy() + expect(files.some(({ name }) => name === 'with-regex')).toBeTruthy() + + const expectedRoutes = [ + [{ pattern: '/products', literal: '/products', methods: ['GET', 'POST'] }], + [ + { + pattern: '/products/:id', + expression: '^\\/products(?:\\/([^\\/]+?))\\/?$', + methods: [], + }, + ], + [ + { + pattern: '/numbers/(\\d+)', + expression: '^\\/numbers(?:\\/(\\d+))\\/?$', + methods: [], + }, + ], + ] for (const file of files) { switch (file.name) { case 'with-literal': - expect(file.routes).toEqual([{ pattern: '/products', literal: '/products', methods: ['GET', 'POST'] }]) + expect(file.routes).toEqual(expectedRoutes[0]) break case 'with-named-group': - expect(file.routes).toEqual([ - { - pattern: '/products/:id', - expression: '^\\/products(?:\\/([^\\/]+?))\\/?$', - methods: [], - }, - ]) + expect(file.routes).toEqual(expectedRoutes[1]) break case 'with-regex': - expect(file.routes).toEqual([ - { - pattern: '/numbers/(\\d+)', - expression: '^\\/numbers(?:\\/(\\d+))\\/?$', - methods: [], - }, - ]) + expect(file.routes).toEqual(expectedRoutes[2]) break @@ -434,8 +443,15 @@ describe.runIf(semver.gte(nodeVersion, '18.13.0'))('V2 functions API', () => { const manifestString = await readFile(manifestPath, { encoding: 'utf8' }) const manifest = JSON.parse(manifestString) - expect(manifest.functions[0].routes[0].methods).toEqual(['GET', 'POST']) + + expect(manifest.functions[0].routes).toEqual(expectedRoutes[0]) expect(manifest.functions[0].buildData.runtimeAPIVersion).toEqual(2) + + expect(manifest.functions[1].routes).toEqual(expectedRoutes[1]) + expect(manifest.functions[1].buildData.runtimeAPIVersion).toEqual(2) + + expect(manifest.functions[2].routes).toEqual(expectedRoutes[2]) + expect(manifest.functions[2].buildData.runtimeAPIVersion).toEqual(2) }) test('Flags invalid values of the `path` in-source configuration property as user errors', async () => { @@ -581,4 +597,65 @@ describe.runIf(semver.gte(nodeVersion, '18.13.0'))('V2 functions API', () => { expect(positionOfBootstrapImport).toBeLessThan(positionOfUserCodeImport) }, ) + + testMany( + 'Includes in the bundle files included in the TOML and in the function source', + ['bundler_default'], + async (options) => { + const fixtureName = 'v2-api-included-files' + const { files, tmpDir } = await zipFixture(fixtureName, { + fixtureDir: FIXTURES_ESM_DIR, + opts: merge(options, { + archiveFormat: ARCHIVE_FORMAT.NONE, + config: { + '*': { + includedFiles: ['blog/post*'], + }, + }, + }), + }) + + const [{ name: archive, entryFilename, includedFiles, runtimeAPIVersion }] = files + const func = await importFunctionFile(`${tmpDir}/${archive}/${entryFilename}`) + const { body: bodyStream, multiValueHeaders = {}, statusCode } = await invokeLambda(func) + const body = await readAsBuffer(bodyStream) + + expect(body).toBe('

Hello world

') + expect(multiValueHeaders['content-type']).toEqual(['text/html']) + expect(statusCode).toBe(200) + expect(runtimeAPIVersion).toBe(2) + expect(includedFiles).toEqual([ + resolve(FIXTURES_ESM_DIR, fixtureName, 'blog/author1.md'), + resolve(FIXTURES_ESM_DIR, fixtureName, 'blog/post1.md'), + resolve(FIXTURES_ESM_DIR, fixtureName, 'blog/post2.md'), + ]) + }, + ) + + test('Uses the bundler specified in the `nodeBundler` property from the in-source configuration', async () => { + const fixtureName = 'v2-api-bundler-none' + const { files } = await zipFixture(fixtureName, { + fixtureDir: FIXTURES_ESM_DIR, + }) + + const unzippedFunctions = await unzipFiles(files) + const originalFile = await readFile(join(FIXTURES_ESM_DIR, fixtureName, 'function.js'), 'utf8') + const bundledFile = await readFile(join(unzippedFunctions[0].unzipPath, 'function.js'), 'utf8') + + expect(originalFile).toBe(bundledFile) + }) + + test('Uses the Node.js version specified in the `nodeVersion` property from the in-source configuration', async () => { + const fixtureName = 'v2-api-node-version' + const { files } = await zipFixture(fixtureName, { + fixtureDir: FIXTURES_ESM_DIR, + }) + + expect( + `nodejs${DEFAULT_NODE_VERSION}.x`, + 'The Node.js version extracted from the function is the same as the default version, which defeats the point of the assertion. If you have updated the default Node.js version, please update the fixture to use a different version.', + ).not.toBe(files[0].runtimeVersion) + expect(files[0].config.nodeVersion).toBe('20') + expect(files[0].runtimeVersion).toBe('nodejs20.x') + }) }) diff --git a/packages/zip-it-and-ship-it/tsconfig.json b/packages/zip-it-and-ship-it/tsconfig.json index 9108ff9327..5d25fd675c 100644 --- a/packages/zip-it-and-ship-it/tsconfig.json +++ b/packages/zip-it-and-ship-it/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist" /* Specify an output folder for all emitted files. */, - "esModuleInterop": true + "esModuleInterop": true, + "strict": true }, "include": ["src"], "exclude": ["tests/**"]