From bae53b9038fc850bef6d0922539171c147af0795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 13 Jun 2024 16:17:53 +0100 Subject: [PATCH 1/9] feat: update method for merging ISC with TOML config --- package-lock.json | 2 +- packages/zip-it-and-ship-it/package.json | 2 +- .../runtimes/node/in_source_config/index.ts | 51 +++++++++++++++++-- .../src/runtimes/node/index.ts | 29 +++++------ .../v2-api-bundler-none/function.js | 10 ++++ .../v2-api-bundler-none/package.json | 3 ++ .../v2-api-included-files/blog/author1.md | 1 + .../v2-api-included-files/blog/post1.md | 1 + .../v2-api-included-files/blog/post2.md | 1 + .../v2-api-included-files/function.js | 10 ++++ .../v2-api-included-files/package.json | 3 ++ .../zip-it-and-ship-it/tests/v2api.test.ts | 47 +++++++++++++++++ 12 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/function.js create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-bundler-none/package.json create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/author1.md create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post1.md create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/blog/post2.md create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/function.js create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-included-files/package.json diff --git a/package-lock.json b/package-lock.json index c5034b2fc8..2447caff5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27856,6 +27856,7 @@ "archiver": "^7.0.0", "common-path-prefix": "^3.0.0", "cp-file": "^10.0.0", + "deepmerge": "^4.3.1", "es-module-lexer": "^1.0.0", "esbuild": "0.19.11", "execa": "^6.0.0", @@ -27902,7 +27903,6 @@ "cardinal": "2.1.1", "cpy": "9.0.1", "decompress": "4.2.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..bd07443152 100644 --- a/packages/zip-it-and-ship-it/package.json +++ b/packages/zip-it-and-ship-it/package.json @@ -49,6 +49,7 @@ "archiver": "^7.0.0", "common-path-prefix": "^3.0.0", "cp-file": "^10.0.0", + "deepmerge": "^4.3.1", "es-module-lexer": "^1.0.0", "esbuild": "0.19.11", "execa": "^6.0.0", @@ -92,7 +93,6 @@ "cardinal": "2.1.1", "cpy": "9.0.1", "decompress": "4.2.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/runtimes/node/in_source_config/index.ts b/packages/zip-it-and-ship-it/src/runtimes/node/in_source_config/index.ts index 5b9e0f51c5..03627bd840 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,5 +1,7 @@ import type { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types' +import deepmerge from 'deepmerge' +import type { FunctionConfig } from '../../../config.js' import { InvocationMode, INVOCATION_MODE } from '../../../function.js' import { TrafficRules } from '../../../manifest.js' import { RateLimitAction, RateLimitAggregator, RateLimitAlgorithm } from '../../../rate_limit.js' @@ -7,7 +9,7 @@ import { FunctionBundlingUserError } from '../../../utils/error.js' import { nonNullable } from '../../../utils/non_nullable.js' import { getRoutes, Route } from '../../../utils/routes.js' import { RUNTIME } from '../../runtime.js' -import { NODE_BUNDLER } from '../bundlers/types.js' +import { NODE_BUNDLER, NodeBundlerName } from '../bundlers/types.js' import { createBindingsMethod } from '../parser/bindings.js' import { traverseNodes } from '../parser/exports.js' import { getImports } from '../parser/imports.js' @@ -19,13 +21,17 @@ import { parse as parseSchedule } from './properties/schedule.js' export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions' export type ISCValues = { - routes?: Route[] - schedule?: string + externalNodeModules?: string[] + ignoredNodeModules?: string[] + generator?: string + includedFiles?: string[] methods?: string[] - trafficRules?: TrafficRules name?: string - generator?: string + nodeBundler?: NodeBundlerName + routes?: Route[] + schedule?: string timeout?: number + trafficRules?: TrafficRules } export interface StaticAnalysisResult extends ISCValues { @@ -184,6 +190,14 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration result.name = configExport.name } + if ( + configExport.nodeBundler === 'esbuild' || + configExport.nodeBundler === 'nft' || + configExport.nodeBundler === 'none' + ) { + result.nodeBundler = configExport.nodeBundler + } + if (typeof configExport.generator === 'string') { result.generator = configExport.generator } @@ -196,6 +210,18 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration result.methods = normalizeMethods(configExport.method, functionName) } + if (configExport.includedFiles !== undefined) { + result.includedFiles = getArrayOfType(configExport.includedFiles, 'string') + } + + if (configExport.externalNodeModules !== undefined) { + result.externalNodeModules = getArrayOfType(configExport.externalNodeModules, 'string') + } + + if (configExport.ignoredNodeModules !== undefined) { + result.ignoredNodeModules = getArrayOfType(configExport.ignoredNodeModules, 'string') + } + result.routes = getRoutes({ functionName, methods: result.methods ?? [], @@ -260,6 +286,21 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration return { ...mergedExports, inputModuleFormat, runtimeAPIVersion: 1 } } +const getArrayOfType = (input: any, type: string): T[] => { + if (!Array.isArray(input)) { + return [] + } + + return input.filter((element) => typeof element === type) as T[] +} + +export const augmentFunctionConfig = ( + config: FunctionConfig, + staticAnalysisResult: StaticAnalysisResult, +): FunctionConfig & StaticAnalysisResult => { + return deepmerge(config, staticAnalysisResult) +} + export type ISCHandlerArg = ArgumentPlaceholder | Expression | SpreadElement | JSXNamespacedName export type ISCExportWithCallExpression = { 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..06799c4559 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 @@ -10,7 +10,7 @@ import { GetSrcFilesFunction, Runtime, RUNTIME, ZipFunction } from '../runtime.j 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 +64,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) const pluginsModulesPath = await getPluginsModulesPath(srcDir) const bundlerName = await getBundlerName({ - config, + config: mergedConfig, extension, featureFlags, mainFile, @@ -89,7 +89,7 @@ const zipFunction: ZipFunction = async function ({ } = await bundler.bundle({ basePath, cache, - config, + config: mergedConfig, extension, featureFlags, filename, @@ -141,13 +141,6 @@ 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 @@ -155,11 +148,11 @@ const zipFunction: ZipFunction = async function ({ 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, @@ -168,9 +161,11 @@ const zipFunction: ZipFunction = async function ({ nativeNodeModules, path: zipPath.path, priority, - trafficRules, + trafficRules: mergedConfig?.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/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/v2api.test.ts b/packages/zip-it-and-ship-it/tests/v2api.test.ts index 467d2369d3..cb1098d982 100644 --- a/packages/zip-it-and-ship-it/tests/v2api.test.ts +++ b/packages/zip-it-and-ship-it/tests/v2api.test.ts @@ -581,4 +581,51 @@ 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) + }) }) From 3bddeddc5fb758abe9167c750f97b2259e17503d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 13 Jun 2024 18:21:38 +0100 Subject: [PATCH 2/9] fix: hide generated properties --- .../src/runtimes/node/in_source_config/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 03627bd840..4071dfdacf 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 @@ -298,7 +298,15 @@ export const augmentFunctionConfig = ( config: FunctionConfig, staticAnalysisResult: StaticAnalysisResult, ): FunctionConfig & StaticAnalysisResult => { - return deepmerge(config, staticAnalysisResult) + return deepmerge(config, { + ...staticAnalysisResult, + + // These are generated properties, so we don't wnat them to be part of + // the merged config object. + inputModuleFormat: undefined, + invocationMode: undefined, + runtimeAPIVersion: undefined, + }) } export type ISCHandlerArg = ArgumentPlaceholder | Expression | SpreadElement | JSXNamespacedName From 7ce5ee5dfaf18db110cf92478010a4b502fcc224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 00:27:48 +0100 Subject: [PATCH 3/9] refactor: use zod --- package-lock.json | 13 +- packages/zip-it-and-ship-it/package.json | 5 +- packages/zip-it-and-ship-it/src/config.ts | 39 ++-- packages/zip-it-and-ship-it/src/main.ts | 2 +- packages/zip-it-and-ship-it/src/manifest.ts | 20 +- packages/zip-it-and-ship-it/src/rate_limit.ts | 85 +++++-- .../src/runtimes/node/bundlers/types.ts | 6 +- .../runtimes/node/in_source_config/index.ts | 220 +++++------------- .../src/runtimes/node/index.ts | 4 +- .../src/runtimes/node/utils/module_format.ts | 6 +- packages/zip-it-and-ship-it/tsconfig.json | 3 +- 11 files changed, 169 insertions(+), 234 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2447caff5d..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", @@ -27856,7 +27864,6 @@ "archiver": "^7.0.0", "common-path-prefix": "^3.0.0", "cp-file": "^10.0.0", - "deepmerge": "^4.3.1", "es-module-lexer": "^1.0.0", "esbuild": "0.19.11", "execa": "^6.0.0", @@ -27881,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" @@ -27903,6 +27911,7 @@ "cardinal": "2.1.1", "cpy": "9.0.1", "decompress": "4.2.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 bd07443152..f1167a9052 100644 --- a/packages/zip-it-and-ship-it/package.json +++ b/packages/zip-it-and-ship-it/package.json @@ -49,7 +49,6 @@ "archiver": "^7.0.0", "common-path-prefix": "^3.0.0", "cp-file": "^10.0.0", - "deepmerge": "^4.3.1", "es-module-lexer": "^1.0.0", "esbuild": "0.19.11", "execa": "^6.0.0", @@ -74,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", @@ -93,6 +93,7 @@ "cardinal": "2.1.1", "cpy": "9.0.1", "decompress": "4.2.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..31330e1d82 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 { 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..6114f48441 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', +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 4071dfdacf..c6987ab4f2 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,15 +1,14 @@ import type { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types' -import deepmerge from 'deepmerge' +import mergeOptions from 'merge-options' +import { z } from 'zod' -import type { FunctionConfig } from '../../../config.js' +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 { RUNTIME } from '../../runtime.js' -import { NODE_BUNDLER, NodeBundlerName } from '../bundlers/types.js' import { createBindingsMethod } from '../parser/bindings.js' import { traverseNodes } from '../parser/exports.js' import { getImports } from '../parser/imports.js' @@ -20,23 +19,10 @@ import { parse as parseSchedule } from './properties/schedule.js' export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions' -export type ISCValues = { - externalNodeModules?: string[] - ignoredNodeModules?: string[] - generator?: string - includedFiles?: string[] - methods?: string[] - name?: string - nodeBundler?: NodeBundlerName - routes?: Route[] - schedule?: string - timeout?: number - trafficRules?: TrafficRules -} - export interface StaticAnalysisResult extends ISCValues { inputModuleFormat?: ModuleFormat invocationMode?: InvocationMode + routes?: Route[] runtimeAPIVersion?: number } @@ -44,6 +30,31 @@ interface FindISCDeclarationsOptions { functionName: string } +const httpMethods = z.preprocess( + (input) => (typeof input === 'string' ? input.toUpperCase() : input), + z.enum(['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE', 'HEAD']), +) + +export const isc = functionConfig + .pick({ + externalNodeModules: true, + generator: true, + includedFiles: true, + ignoredNodeModules: true, + name: true, + nodeBundler: true, + schedule: true, + timeout: true, + }) + .extend({ + methods: z.array(httpMethods).optional().catch(undefined), + path: z.string().optional().catch(undefined), + preferStatic: z.boolean().optional(), + rateLimit: rateLimit.optional().catch(undefined), + }) + +export type ISCValues = z.infer + const validateScheduleFunction = (functionFound: boolean, scheduleFound: boolean, functionName: string): void => { if (!functionFound) { throw new FunctionBundlingUserError( @@ -60,83 +71,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. @@ -182,58 +116,24 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration runtimeAPIVersion: 2, } - if (typeof configExport.schedule === 'string') { - result.schedule = configExport.schedule - } - - if (typeof configExport.name === 'string') { - result.name = configExport.name - } - - if ( - configExport.nodeBundler === 'esbuild' || - configExport.nodeBundler === 'nft' || - configExport.nodeBundler === 'none' - ) { - result.nodeBundler = configExport.nodeBundler - } - - if (typeof configExport.generator === 'string') { - result.generator = configExport.generator - } - - if (typeof configExport.timeout === 'number') { - result.timeout = configExport.timeout - } - - if (configExport.method !== undefined) { - result.methods = normalizeMethods(configExport.method, functionName) - } - - if (configExport.includedFiles !== undefined) { - result.includedFiles = getArrayOfType(configExport.includedFiles, 'string') - } - - if (configExport.externalNodeModules !== undefined) { - result.externalNodeModules = getArrayOfType(configExport.externalNodeModules, 'string') - } - - if (configExport.ignoredNodeModules !== undefined) { - result.ignoredNodeModules = getArrayOfType(configExport.ignoredNodeModules, 'string') - } - - result.routes = getRoutes({ - functionName, - methods: result.methods ?? [], - path: configExport.path, - preferStatic: configExport.preferStatic === true, - }) - - if (configExport.rateLimit !== undefined) { - result.trafficRules = getTrafficRulesConfig(configExport.rateLimit, functionName) + try { + const iscValues = isc.parse(configExport) + console.log('-----> ISC', iscValues) + const routes = getRoutes({ + functionName, + methods: iscValues.methods as string[], + path: iscValues.path, + preferStatic: iscValues.preferStatic === true, + }) + + return { + ...result, + ...iscValues, + routes, + } + } catch (error) { + return result } - - return result } const iscExports = handlerExports @@ -286,27 +186,19 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration return { ...mergedExports, inputModuleFormat, runtimeAPIVersion: 1 } } -const getArrayOfType = (input: any, type: string): T[] => { - if (!Array.isArray(input)) { - return [] - } - - return input.filter((element) => typeof element === type) as T[] -} - export const augmentFunctionConfig = ( config: FunctionConfig, staticAnalysisResult: StaticAnalysisResult, ): FunctionConfig & StaticAnalysisResult => { - return deepmerge(config, { - ...staticAnalysisResult, - - // These are generated properties, so we don't wnat them to be part of - // the merged config object. - inputModuleFormat: undefined, - invocationMode: undefined, - runtimeAPIVersion: undefined, - }) + const iscConfig: FunctionConfig = {} + + for (const key in staticAnalysisResult) { + if (key in isc.shape) { + iscConfig[key] = staticAnalysisResult[key] + } + } + + return mergeOptions(config, iscConfig) } 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 06799c4559..76a16575e8 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,6 +4,7 @@ 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' @@ -144,6 +145,7 @@ const zipFunction: ZipFunction = async function ({ 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, @@ -161,7 +163,7 @@ const zipFunction: ZipFunction = async function ({ nativeNodeModules, path: zipPath.path, priority, - trafficRules: mergedConfig?.trafficRules, + trafficRules, runtimeVersion: runtimeAPIVersion === 2 ? getNodeRuntimeForV2(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/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/**"] From 923aba7e2ee53c5b2829280a7419c4409704efde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 00:31:28 +0100 Subject: [PATCH 4/9] chore: clean up --- .../src/runtimes/node/in_source_config/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 c6987ab4f2..3d14a290a9 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 @@ -118,7 +118,6 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration try { const iscValues = isc.parse(configExport) - console.log('-----> ISC', iscValues) const routes = getRoutes({ functionName, methods: iscValues.methods as string[], @@ -192,6 +191,9 @@ export const augmentFunctionConfig = ( ): FunctionConfig & StaticAnalysisResult => { const iscConfig: FunctionConfig = {} + // Some of the properties in the static analysis result are generated by us. + // They don't belong in the config file, so we filter those out by checking + // whether they belong to the ISC schema. for (const key in staticAnalysisResult) { if (key in isc.shape) { iscConfig[key] = staticAnalysisResult[key] From e6d12432f591494058ec51f01e3c5d217ceb2529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 00:35:40 +0100 Subject: [PATCH 5/9] fix: fix imports --- packages/zip-it-and-ship-it/src/rate_limit.ts | 2 +- packages/zip-it-and-ship-it/src/runtimes/runtime.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 6114f48441..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,6 +1,6 @@ import { z } from 'zod' -interface TrafficRules { +export interface TrafficRules { action: { type: string config: { 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' From c95fc75a5d6c969e79b0a6feb3f3b9a50846bcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 09:52:07 +0100 Subject: [PATCH 6/9] fix: fix validation --- packages/zip-it-and-ship-it/src/main.ts | 2 +- .../runtimes/node/in_source_config/index.ts | 155 ++++++++++-------- .../src/runtimes/node/index.ts | 2 +- .../src/runtimes/runtime.ts | 2 + .../src/utils/format_result.ts | 2 +- .../runtimes/node/in_source_config.test.ts | 150 ++++++++++------- 6 files changed, 178 insertions(+), 135 deletions(-) diff --git a/packages/zip-it-and-ship-it/src/main.ts b/packages/zip-it-and-ship-it/src/main.ts index 31330e1d82..979708fb43 100644 --- a/packages/zip-it-and-ship-it/src/main.ts +++ b/packages/zip-it-and-ship-it/src/main.ts @@ -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/runtimes/node/in_source_config/index.ts b/packages/zip-it-and-ship-it/src/runtimes/node/in_source_config/index.ts index 3d14a290a9..a0af782000 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 @@ -6,8 +6,7 @@ import { FunctionConfig, functionConfig } from '../../../config.js' import { InvocationMode, INVOCATION_MODE } from '../../../function.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 { createBindingsMethod } from '../parser/bindings.js' import { traverseNodes } from '../parser/exports.js' @@ -19,7 +18,8 @@ import { parse as parseSchedule } from './properties/schedule.js' export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions' -export interface StaticAnalysisResult extends ISCValues { +export interface StaticAnalysisResult { + config: InSourceConfig inputModuleFormat?: ModuleFormat invocationMode?: InvocationMode routes?: Route[] @@ -34,8 +34,9 @@ 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 isc = functionConfig +export const inSourceConfig = functionConfig .pick({ externalNodeModules: true, generator: true, @@ -47,13 +48,19 @@ export const isc = functionConfig timeout: true, }) .extend({ - methods: z.array(httpMethods).optional().catch(undefined), - path: z.string().optional().catch(undefined), - preferStatic: z.boolean().optional(), + method: z + .union([httpMethods, z.array(httpMethods)]) + .transform((input) => (Array.isArray(input) ? input : [input])) + .optional(), + path: z + .union([path, z.array(path)], { errorMap: () => ({ message: 'Must be a string or array of strings' }) }) + .transform((input) => (Array.isArray(input) ? input : [input])) + .optional(), + preferStatic: z.boolean().optional().catch(undefined), rateLimit: rateLimit.optional().catch(undefined), }) -export type ISCValues = z.infer +export type InSourceConfig = z.infer const validateScheduleFunction = (functionFound: boolean, scheduleFound: boolean, functionName: string): void => { if (!functionFound) { @@ -82,7 +89,9 @@ export const parseFile = async ( const source = await safelyReadSource(sourcePath) if (source === null) { - return {} + return { + config: {}, + } } return parseSource(source, { functionName }) @@ -97,7 +106,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)) @@ -112,95 +123,97 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration if (isV2API) { const result: StaticAnalysisResult = { + config: {}, inputModuleFormat, runtimeAPIVersion: 2, } + const { data, error, success } = inSourceConfig.safeParse(configExport) - try { - const iscValues = isc.parse(configExport) - const routes = getRoutes({ + if (success) { + result.config = data + result.routes = getRoutes({ functionName, - methods: iscValues.methods as string[], - path: iscValues.path, - preferStatic: iscValues.preferStatic === true, + methods: data.method ?? [], + path: data.path, + preferStatic: data.preferStatic, }) - - return { - ...result, - ...iscValues, - routes, - } - } catch (error) { - return result + } else { + // TODO: Handle multiple errors. + const [issue] = error.issues + + throw new FunctionBundlingUserError( + `Function ${functionName} has a configuration error on '${issue.path.join('.')}': ${issue.message}`, + { + functionName, + runtime: RUNTIME.JAVASCRIPT, + }, + ) } + + return result } - 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 - } + const result: StaticAnalysisResult = { + config: {}, + inputModuleFormat, + runtimeAPIVersion: 1, + } - const { args, local: exportName } = node - const matchingImport = imports.find(({ local: importName }) => importName === exportName) + 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 + } - if (matchingImport === undefined) { - return null - } + const { args, local: exportName } = node + const matchingImport = imports.find(({ local: importName }) => importName === exportName) - switch (matchingImport.imported) { - case 'schedule': { - const parsed = parseSchedule({ args }, getAllBindings) + if (matchingImport === undefined) { + return + } - scheduledFunctionFound = true - if (parsed.schedule) { - scheduleFound = true - } + switch (matchingImport.imported) { + case 'schedule': { + const parsed = parseSchedule({ args }, getAllBindings) - return parsed + scheduledFunctionFound = true + if (parsed.schedule) { + scheduleFound = true } - case 'stream': { - return { - invocationMode: INVOCATION_MODE.Stream, - } + if (parsed.schedule !== undefined) { + result.config.schedule = parsed.schedule } - default: - // no-op + return } - return null - }) - .filter(nonNullable) + case 'stream': { + result.invocationMode = INVOCATION_MODE.Stream + + return + } + + default: + // no-op + } + + return + }) if (scheduledFunctionExpected) { validateScheduleFunction(scheduledFunctionFound, scheduleFound, functionName) } - const mergedExports: ISCValues = iscExports.reduce((acc, obj) => ({ ...acc, ...obj }), {}) - - return { ...mergedExports, inputModuleFormat, runtimeAPIVersion: 1 } + return result } export const augmentFunctionConfig = ( - config: FunctionConfig, - staticAnalysisResult: StaticAnalysisResult, -): FunctionConfig & StaticAnalysisResult => { - const iscConfig: FunctionConfig = {} - - // Some of the properties in the static analysis result are generated by us. - // They don't belong in the config file, so we filter those out by checking - // whether they belong to the ISC schema. - for (const key in staticAnalysisResult) { - if (key in isc.shape) { - iscConfig[key] = staticAnalysisResult[key] - } - } - - return mergeOptions(config, iscConfig) + 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 76a16575e8..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 @@ -65,7 +65,7 @@ const zipFunction: ZipFunction = async function ({ const staticAnalysisResult = await parseFile(mainFile, { functionName: name }) const runtimeAPIVersion = staticAnalysisResult.runtimeAPIVersion === 2 ? 2 : 1 - const mergedConfig = augmentFunctionConfig(config, staticAnalysisResult) + const mergedConfig = augmentFunctionConfig(config, staticAnalysisResult.config) const pluginsModulesPath = await getPluginsModulesPath(srcDir) const bundlerName = await getBundlerName({ config: mergedConfig, 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 e527c97adc..960feb95ff 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/runtime.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/runtime.ts @@ -7,6 +7,7 @@ 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' +import type { Route } from '../utils/routes.js' import type { NodeBundlerName } from './node/bundlers/types.js' import type { StaticAnalysisResult } from './node/in_source_config/index.js' @@ -56,6 +57,7 @@ export interface ZipFunctionResult { nativeNodeModules?: object path: string priority?: number + routes?: Route[] trafficRules?: TrafficRules runtimeVersion?: string staticAnalysisResult?: StaticAnalysisResult 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/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, }) }) }) From 965e24e47ffa5aa3426d453c92f852b88d8af30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 10:24:27 +0100 Subject: [PATCH 7/9] chore: add assertions --- .../zip-it-and-ship-it/tests/v2api.test.ts | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) 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 cb1098d982..93a1085ca7 100644 --- a/packages/zip-it-and-ship-it/tests/v2api.test.ts +++ b/packages/zip-it-and-ship-it/tests/v2api.test.ts @@ -396,34 +396,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 +442,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 () => { From 5e9471fffcf56726eb3a999da8fd31a02dfa8f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 10:36:13 +0100 Subject: [PATCH 8/9] refactor: small things --- .../src/runtimes/node/in_source_config/index.ts | 10 +++++++--- packages/zip-it-and-ship-it/src/runtimes/runtime.ts | 2 -- 2 files changed, 7 insertions(+), 5 deletions(-) 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 a0af782000..b6379d008c 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 @@ -30,6 +30,8 @@ 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']), @@ -49,12 +51,14 @@ export const inSourceConfig = functionConfig }) .extend({ method: z - .union([httpMethods, z.array(httpMethods)]) - .transform((input) => (Array.isArray(input) ? input : [input])) + .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((input) => (Array.isArray(input) ? input : [input])) + .transform(ensureArray) .optional(), preferStatic: z.boolean().optional().catch(undefined), rateLimit: rateLimit.optional().catch(undefined), 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 960feb95ff..e527c97adc 100644 --- a/packages/zip-it-and-ship-it/src/runtimes/runtime.ts +++ b/packages/zip-it-and-ship-it/src/runtimes/runtime.ts @@ -7,7 +7,6 @@ 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' -import type { Route } from '../utils/routes.js' import type { NodeBundlerName } from './node/bundlers/types.js' import type { StaticAnalysisResult } from './node/in_source_config/index.js' @@ -57,7 +56,6 @@ export interface ZipFunctionResult { nativeNodeModules?: object path: string priority?: number - routes?: Route[] trafficRules?: TrafficRules runtimeVersion?: string staticAnalysisResult?: StaticAnalysisResult From ef47e1527565e80bf55b776e2a3ad62f979250f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 14 Jun 2024 10:49:59 +0100 Subject: [PATCH 9/9] feat: support `nodeVersion` in ISC --- .../src/runtimes/node/in_source_config/index.ts | 1 + .../fixtures-esm/v2-api-node-version/function.js | 10 ++++++++++ .../fixtures-esm/v2-api-node-version/package.json | 3 +++ packages/zip-it-and-ship-it/tests/v2api.test.ts | 15 +++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/function.js create mode 100644 packages/zip-it-and-ship-it/tests/fixtures-esm/v2-api-node-version/package.json 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 b6379d008c..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 @@ -46,6 +46,7 @@ export const inSourceConfig = functionConfig ignoredNodeModules: true, name: true, nodeBundler: true, + nodeVersion: true, schedule: true, timeout: true, }) 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/v2api.test.ts b/packages/zip-it-and-ship-it/tests/v2api.test.ts index 93a1085ca7..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' @@ -643,4 +644,18 @@ describe.runIf(semver.gte(nodeVersion, '18.13.0'))('V2 functions API', () => { 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') + }) })